Compare commits
349 Commits
v0.10.3-et
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 |
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
/testdata
|
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
|
62
README.md
@@ -21,11 +21,13 @@
|
||||
|
||||
* [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)
|
||||
@@ -58,6 +60,8 @@ The full list is described below in the [Changes](#changes) section.
|
||||
|
||||
### 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)
|
||||
@@ -97,26 +101,46 @@ The following changes are already implemented:
|
||||
* 🛑 [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 "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)
|
||||
* 🏷️ [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)
|
||||
|
||||
_the list will be updated as new changes are added_
|
||||
#### 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 and Postgres DB),
|
||||
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` in your browser, login using the following credentials:
|
||||
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.
|
||||
@@ -148,13 +172,9 @@ services:
|
||||
|
||||
### Prefilling login form
|
||||
|
||||
You can prefill `username` and `homeserver` fields on the login page using GET parameters, example:
|
||||
You can prefill all fields on the login page using GET parameters.
|
||||
|
||||
```
|
||||
https://matrix.example.com/synapse-admin/?username=admin&server=matrix.example.com
|
||||
```
|
||||
|
||||
That way `username` and `homeserver` fields will be pre-filled with `admin` and `https://matrix.example.com` respectively.
|
||||
[Documentation](./docs/prefill-login-form.md)
|
||||
|
||||
|
||||
### Restricting available homeserver
|
||||
@@ -163,6 +183,12 @@ You can restrict the homeserver(s), so that the user can no longer define it him
|
||||
|
||||
[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,
|
||||
@@ -246,6 +272,7 @@ You have three options:
|
||||
hostname: synapse-admin
|
||||
build:
|
||||
context: https://github.com/etkecc/synapse-admin.git
|
||||
dockerfile: Dockerfile.build
|
||||
args:
|
||||
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
# - NODE_OPTIONS="--max_old_space_size=1024"
|
||||
@@ -274,7 +301,7 @@ Example for Traefik:
|
||||
```yml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:mimolette
|
||||
image: traefik:v3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
@@ -287,11 +314,12 @@ services:
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)&&PathPrefix(`/admin`)"
|
||||
- "traefik.http.routers.synapse-admin.middlewares=admin,admin_path"
|
||||
- "traefik.http.middlewares.admin.redirectregex.regex=^(.*)/admin/?"
|
||||
- "traefik.http.middlewares.admin.redirectregex.replacement=$${1}/admin/"
|
||||
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
||||
- "traefik.http.routers.admin.rule=Host(`example.com`) && PathPrefix(`/admin`)"
|
||||
- "traefik.http.services.admin.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.admin-slashless-redirect.redirectregex.regex=(/admin)$$"
|
||||
- "traefik.http.middlewares.admin-slashless-redirect.redirectregex.replacement=$${1}/"
|
||||
- "traefik.http.middlewares.admin-strip-prefix.stripprefix.prefixes=/admin"
|
||||
- "traefik.http.routers.admin.middlewares=admin-slashless-redirect,admin-strip-prefix"
|
||||
```
|
||||
|
||||
## Development
|
||||
|
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
entrypoint: python
|
||||
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
|
||||
ports:
|
||||
@@ -18,3 +18,17 @@ services:
|
||||
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
|
||||
|
||||
|
@@ -5,6 +5,7 @@ services:
|
||||
image: ghcr.io/etkecc/synapse-admin:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.build
|
||||
|
||||
# to use the docker-compose as standalone without a local repo clone,
|
||||
# replace the context definition with this:
|
||||
|
39
docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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:
|
||||
|
||||
* [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)
|
||||
|
||||
## Deployment
|
||||
|
||||
* [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md)
|
@@ -28,6 +28,12 @@ In this case, you could provide the configuration in the `/.well-known/matrix/cl
|
||||
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$`
|
||||
|
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"
|
||||
}
|
||||
}
|
||||
```
|
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`.
|
@@ -2,12 +2,30 @@ import type { JestConfigWithTsJest } from "ts-jest";
|
||||
|
||||
const config: JestConfigWithTsJest = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
testEnvironment: "jest-fixed-jsdom",
|
||||
collectCoverage: true,
|
||||
coveragePathIgnorePatterns: ["node_modules", "dist"],
|
||||
coverageDirectory: "<rootDir>/coverage/",
|
||||
coverageReporters: ["html", "text", "text-summary", "cobertura"],
|
||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||
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;
|
||||
|
2
justfile
@@ -16,6 +16,8 @@ run-dev:
|
||||
@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..."
|
||||
|
92
package.json
@@ -11,69 +11,70 @@
|
||||
"url": "https://github.com/etkecc/synapse-admin"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^18.3.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.14.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"@types/react": "^19.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||
"@typescript-eslint/parser": "^8.29.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-yaml": "^1.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"prettier": "^3.5.3",
|
||||
"react-test-renderer": "^19.1.0",
|
||||
"ts-jest": "^29.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-version-mark": "^0.1.2"
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.29.1",
|
||||
"vite": "^6.2.6",
|
||||
"vite-plugin-version-mark": "^0.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@emotion/styled": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@haleos/ra-language-german": "^1.0.0",
|
||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||
"@mui/icons-material": "^6.1.8",
|
||||
"@mui/material": "^6.1.8",
|
||||
"@mui/utils": "^5.16.6",
|
||||
"@tanstack/react-query": "^5.61.3",
|
||||
"@mui/icons-material": "^6.4.8",
|
||||
"@mui/material": "^6.4.8",
|
||||
"@mui/utils": "^6.4.9",
|
||||
"@tanstack/react-query": "^5.74.0",
|
||||
"history": "^5.3.0",
|
||||
"jest-fixed-jsdom": "^0.0.9",
|
||||
"lodash": "^4.17.21",
|
||||
"papaparse": "^5.4.1",
|
||||
"ra-core": "^5.3.4",
|
||||
"ra-i18n-polyglot": "^5.3.4",
|
||||
"ra-language-english": "^5.3.4",
|
||||
"ra-language-farsi": "^5.0.0",
|
||||
"ra-language-french": "^5.4.0",
|
||||
"papaparse": "^5.5.1",
|
||||
"ra-core": "^5.4.4",
|
||||
"ra-i18n-polyglot": "^5.4.4",
|
||||
"ra-language-english": "^5.4.4",
|
||||
"ra-language-farsi": "^5.1.0",
|
||||
"ra-language-french": "^5.7.2",
|
||||
"ra-language-italian": "^3.13.1",
|
||||
"ra-language-russian": "^4.14.2",
|
||||
"react": "^18.3.1",
|
||||
"react-admin": "^5.4.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-is": "^18.3.1",
|
||||
"react-router": "^6.26.2",
|
||||
"react-router-dom": "^6.28.0"
|
||||
"ra-language-russian": "^5.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-admin": "^5.7.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-is": "^19.1.0",
|
||||
"ts-jest-mock-import-meta": "^1.3.0",
|
||||
"react-router": "^7.5.0",
|
||||
"react-router-dom": "^7.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
"build": "vite build",
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ignore-path .gitignore --ignore-pattern testdata/ --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",
|
||||
"test": "yarn jest",
|
||||
"test:watch": "yarn jest --watch"
|
||||
@@ -86,15 +87,13 @@
|
||||
"import",
|
||||
"prettier",
|
||||
"unused-imports",
|
||||
"@typescript-eslint",
|
||||
"yaml"
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/stylistic",
|
||||
"plugin:import/typescript",
|
||||
"plugin:yaml/legacy"
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
@@ -133,8 +132,7 @@
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"unused-imports/no-unused-imports-ts": 2
|
||||
]
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
|
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 |
@@ -1,12 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
fetchMock.enableMocks();
|
||||
|
||||
jest.mock("./synapse/authProvider", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
logout: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
import App from "./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 () => {
|
||||
render(<App />);
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await screen.findAllByText("Welcome to Synapse Admin");
|
||||
});
|
||||
});
|
||||
|
25
src/App.tsx
@@ -1,12 +1,17 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { merge } from "lodash";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
|
||||
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Route } from "react-router-dom";
|
||||
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import UserImport from "./components/UserImport";
|
||||
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 englishMessages from "./i18n/en";
|
||||
import frenchMessages from "./i18n/fr";
|
||||
@@ -23,8 +28,6 @@ import userMediaStats from "./resources/user_media_statistics";
|
||||
import users from "./resources/users";
|
||||
import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Config } from "./utils/config";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
@@ -64,6 +67,14 @@ export const App = () => (
|
||||
>
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<UserImport />} />
|
||||
<Route path="/server_status" element={<ServerStatusPage />} />
|
||||
<Route path="/server_actions" element={<ServerActionsPage />} />
|
||||
<Route path="/server_actions/scheduled/:id/show" element={<ScheduledCommandShow />} />
|
||||
<Route path="/server_actions/scheduled/:id" element={<ScheduledCommandEdit />} />
|
||||
<Route path="/server_actions/scheduled/create" element={<ScheduledCommandEdit />} />
|
||||
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
@@ -86,8 +97,4 @@ export const App = () => (
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export const AppContext = createContext({});
|
||||
|
||||
export const useAppContext = () => useContext(AppContext) as Config;
|
||||
|
||||
export default App;
|
||||
|
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;
|
@@ -1,9 +1,27 @@
|
||||
import { CheckForApplicationUpdate, AppBar, TitlePortal, InspectorButton, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin";
|
||||
import { LoginMethod } from "../pages/LoginPage";
|
||||
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { Icons, DefaultIcon } from "../utils/icons";
|
||||
import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
|
||||
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);
|
||||
@@ -46,55 +64,106 @@ const AdminUserMenu = () => {
|
||||
};
|
||||
|
||||
const AdminAppBar = () => {
|
||||
return (<AppBar userMenu={<AdminUserMenu />}>
|
||||
<TitlePortal />
|
||||
<InspectorButton />
|
||||
</AppBar>);
|
||||
return (
|
||||
<AppBar userMenu={<AdminUserMenu />}>
|
||||
<TitlePortal />
|
||||
<ServerStatusBadge />
|
||||
<ServerNotificationsBadge />
|
||||
<InspectorButton />
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminMenu = (props) => {
|
||||
const AdminMenu = props => {
|
||||
const [menu, setMenu] = useState([] as MenuItem[]);
|
||||
useEffect(() => setMenu(GetConfig().menu), []);
|
||||
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}>
|
||||
<Menu.ResourceItems />
|
||||
{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}
|
||||
{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}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
}
|
||||
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 />
|
||||
</>
|
||||
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,6 +1,7 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { RecordContextProvider } from "react-admin";
|
||||
import { act } from "react";
|
||||
import { RecordContextProvider } from "react-admin";
|
||||
|
||||
import AvatarField from "./AvatarField";
|
||||
|
||||
describe("AvatarField", () => {
|
||||
@@ -8,7 +9,7 @@ describe("AvatarField", () => {
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
|
||||
blob: () => Promise.resolve(new Blob(["mock image data"], { type: "image/jpeg" })),
|
||||
})
|
||||
) as jest.Mock;
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { get } from "lodash";
|
||||
import { Avatar, AvatarProps, Badge, Tooltip } from "@mui/material";
|
||||
import { FieldProps, useRecordContext, useTranslate } from "react-admin";
|
||||
import { get } from "lodash";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FieldProps, useRecordContext, useTranslate } from "react-admin";
|
||||
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
import { isMXID, isASManaged } from "../utils/mxid";
|
||||
|
||||
@@ -96,12 +97,15 @@ const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
|
||||
{letter}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
</Tooltip>);
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||
{letter}
|
||||
</Avatar>);
|
||||
return (
|
||||
<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||
{letter}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarField;
|
||||
|
@@ -1,9 +1,21 @@
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
import { 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";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
import {
|
||||
SimpleForm,
|
||||
BooleanInput,
|
||||
useTranslate,
|
||||
RaRecord,
|
||||
useNotify,
|
||||
useRedirect,
|
||||
useDelete,
|
||||
NotificationType,
|
||||
useDeleteMany,
|
||||
Identifier,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
|
||||
interface DeleteRoomButtonProps {
|
||||
selectedIds: Identifier[];
|
||||
@@ -13,7 +25,7 @@ interface DeleteRoomButtonProps {
|
||||
|
||||
const resourceName = "rooms";
|
||||
|
||||
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = props => {
|
||||
const translate = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [block, setBlock] = useState(false);
|
||||
@@ -28,7 +40,7 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = (values: {block: boolean}) => {
|
||||
const handleDelete = (values: { block: boolean }) => {
|
||||
deleteMany(
|
||||
resourceName,
|
||||
{ ids: recordIds, meta: values },
|
||||
@@ -39,8 +51,7 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||
unselectAll();
|
||||
redirect("/rooms");
|
||||
},
|
||||
onError: (error) =>
|
||||
notify("resources.rooms.action.erase.failure", { type: 'error' as NotificationType }),
|
||||
onError: error => notify("resources.rooms.action.erase.failure", { type: "error" as NotificationType }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@@ -1,9 +1,21 @@
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
import { 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";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
import {
|
||||
SimpleForm,
|
||||
BooleanInput,
|
||||
useTranslate,
|
||||
RaRecord,
|
||||
useNotify,
|
||||
useRedirect,
|
||||
useDelete,
|
||||
NotificationType,
|
||||
useDeleteMany,
|
||||
Identifier,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
|
||||
interface DeleteUserButtonProps {
|
||||
selectedIds: Identifier[];
|
||||
@@ -13,7 +25,7 @@ interface DeleteUserButtonProps {
|
||||
|
||||
const resourceName = "users";
|
||||
|
||||
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||
const DeleteUserButton: React.FC<DeleteUserButtonProps> = props => {
|
||||
const translate = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteMedia, setDeleteMedia] = useState(false);
|
||||
@@ -29,7 +41,7 @@ const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
|
||||
const handleDelete = (values: { deleteMedia: boolean; redactEvents: boolean }) => {
|
||||
deleteMany(
|
||||
resourceName,
|
||||
{ ids: recordIds, meta: values },
|
||||
@@ -39,14 +51,13 @@ const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||
messageArgs: {
|
||||
smart_count: recordIds.length,
|
||||
},
|
||||
type: 'info' as NotificationType,
|
||||
type: "info" as NotificationType,
|
||||
});
|
||||
handleDialogClose();
|
||||
unselectAll();
|
||||
redirect("/users");
|
||||
},
|
||||
onError: (error) =>
|
||||
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
|
||||
onError: error => notify("ra.notification.data_provider_error", { type: "error" as NotificationType }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
||||
|
||||
import { isASManaged } from "../utils/mxid";
|
||||
|
||||
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||
|
@@ -1,15 +1,20 @@
|
||||
import { Stack, Switch, Typography } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRecordContext } from "react-admin";
|
||||
import { useNotify } from "react-admin";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Switch, Typography } from "@mui/material";
|
||||
|
||||
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
|
||||
|
||||
const experimentalFeaturesMap = {
|
||||
msc3881: "enable remotely toggling push notifications for another client",
|
||||
msc3575: "enable experimental sliding sync support",
|
||||
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 ExperimentalFeatureRow = (props: {
|
||||
featureKey: string;
|
||||
featureValue: boolean;
|
||||
updateFeature: (feature_name: string, feature_value: boolean) => void;
|
||||
}) => {
|
||||
const featureKey = props.featureKey;
|
||||
const featureValue = props.featureValue;
|
||||
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
|
||||
@@ -20,34 +25,33 @@ const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boole
|
||||
props.updateFeature(featureKey, event.target.checked);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
padding: 2,
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Switch checked={checked} onChange={handleChange} />
|
||||
<Stack>
|
||||
<Typography
|
||||
>
|
||||
<Switch checked={checked} onChange={handleChange} />
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: "medium",
|
||||
color: "text.primary"
|
||||
fontWeight: "medium",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
>
|
||||
{featureKey}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{featureDescription}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const ExperimentalFeaturesList = () => {
|
||||
const record = useRecordContext();
|
||||
@@ -62,36 +66,35 @@ export const ExperimentalFeaturesList = () => {
|
||||
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;
|
||||
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",
|
||||
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>
|
||||
</>
|
||||
}
|
||||
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;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Avatar, Box, Link, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const Footer = () => {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
@@ -13,36 +13,46 @@ const Footer = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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" }} />
|
||||
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">
|
||||
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>
|
||||
<Link sx={{ fontWeight: "bold" }} href="https://matrix.to/#/#synapse-admin:etke.cc" target="_blank">
|
||||
#synapse-admin:etke.cc
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1,59 +1,58 @@
|
||||
import { styled } from "@mui/material/styles";
|
||||
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",
|
||||
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",
|
||||
},
|
||||
}
|
||||
));
|
||||
[`& .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 MessageIcon from "@mui/icons-material/Message";
|
||||
import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
RaRecord,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
@@ -104,7 +103,7 @@ export const ServerNoticeBulkButton = () => {
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
const { mutate: sendNotices, isPending } = useMutation({
|
||||
mutationFn: (data) =>
|
||||
mutationFn: data =>
|
||||
dataProvider.createMany("servernotices", {
|
||||
ids: selectedIds,
|
||||
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;
|
@@ -1,575 +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, returnMXID } from "../utils/mxid";
|
||||
import { generateRandomPassword } from "../utils/password";
|
||||
|
||||
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("update");
|
||||
|
||||
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 */
|
||||
meta.fields = meta.fields?.map(f => f.trim().toLowerCase());
|
||||
const missingFields = expectedFields.filter(eF => !meta.fields?.find(mF => eF === mF));
|
||||
|
||||
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);
|
||||
// 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) {
|
||||
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 === "") {
|
||||
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 = (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 UserImport = FilePicker;
|
||||
export default UserImport;
|
@@ -1,10 +1,18 @@
|
||||
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 { TextField } from "@mui/material";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
const RateLimitRow = ({ limit, value, updateRateLimit }: { limit: string, value: any, updateRateLimit: (limit: string, value: any) => void }) => {
|
||||
const RateLimitRow = ({
|
||||
limit,
|
||||
value,
|
||||
updateRateLimit,
|
||||
}: {
|
||||
limit: string;
|
||||
value: any;
|
||||
updateRateLimit: (limit: string, value: any) => void;
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -16,35 +24,34 @@ const RateLimitRow = ({ limit, value, updateRateLimit }: { limit: string, value:
|
||||
updateRateLimit(limit, value);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
spacing={1}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
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>
|
||||
>
|
||||
<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>
|
||||
</Stack>
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const UserRateLimits = () => {
|
||||
const translate = useTranslate();
|
||||
@@ -62,36 +69,31 @@ const UserRateLimits = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRateLimits = async () => {
|
||||
const rateLimits = await dataProvider.getRateLimits(record.id);
|
||||
if (Object.keys(rateLimits).length > 0) {
|
||||
setRateLimits(rateLimits);
|
||||
}
|
||||
const fetchRateLimits = async () => {
|
||||
const rateLimits = await dataProvider.getRateLimits(record.id);
|
||||
if (Object.keys(rateLimits).length > 0) {
|
||||
setRateLimits(rateLimits);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRateLimits();
|
||||
fetchRateLimits();
|
||||
}, []);
|
||||
|
||||
const updateRateLimit = async (limit: string, value: any) => {
|
||||
let updatedRateLimits = { ...rateLimits, [limit]: value };
|
||||
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>
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column">
|
||||
{Object.keys(rateLimits).map((limit: string) => (
|
||||
<RateLimitRow key={limit} limit={limit} value={rateLimits[limit]} updateRateLimit={updateRateLimit} />
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserRateLimits;
|
||||
|
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,17 +1,26 @@
|
||||
import { get } from "lodash";
|
||||
import { useState } from "react";
|
||||
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
import IconCancel from "@mui/icons-material/Cancel";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
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 LockIcon from "@mui/icons-material/Lock";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import DownloadingIcon from '@mui/icons-material/Downloading';
|
||||
import { Grid2 as Grid, Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } 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 { useMutation } from "@tanstack/react-query";
|
||||
import { get } from "lodash";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BooleanInput,
|
||||
Button,
|
||||
@@ -30,10 +39,10 @@ import {
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { dateParser } from "../utils/date";
|
||||
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
||||
import { dateParser } from "../utils/date";
|
||||
import decodeURLComponent from "../utils/decodeURLComponent";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
|
||||
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
@@ -54,24 +63,9 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
<DialogContent>
|
||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||
<DateTimeInput
|
||||
source="before_ts"
|
||||
label="delete_media.fields.before_ts"
|
||||
defaultValue={0}
|
||||
parse={dateParser}
|
||||
/>
|
||||
<NumberInput
|
||||
source="size_gt"
|
||||
label="delete_media.fields.size_gt"
|
||||
defaultValue={0}
|
||||
min={0}
|
||||
step={1024}
|
||||
/>
|
||||
<BooleanInput
|
||||
source="keep_profiles"
|
||||
label="delete_media.fields.keep_profiles"
|
||||
defaultValue={true}
|
||||
/>
|
||||
<DateTimeInput source="before_ts" label="delete_media.fields.before_ts" defaultValue={0} parse={dateParser} />
|
||||
<NumberInput source="size_gt" label="delete_media.fields.size_gt" defaultValue={0} min={0} step={1024} />
|
||||
<BooleanInput source="keep_profiles" label="delete_media.fields.keep_profiles" defaultValue={true} />
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -124,6 +118,80 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PurgeRemoteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const PurgeRemoteMediaToolbar = (props: ToolbarProps) => (
|
||||
<Toolbar {...props}>
|
||||
<SaveButton label="purge_remote_media.action.send" icon={<DeleteSweepIcon />} />
|
||||
<Button label="ra.action.cancel" onClick={onClose}>
|
||||
<IconCancel />
|
||||
</Button>
|
||||
</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 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 (
|
||||
<>
|
||||
<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>
|
||||
<PurgeRemoteMediaDialog open={open} onClose={closeDialog} onSubmit={purgeRemoteMedia} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProtectMediaButton = (props: ButtonProps) => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
@@ -314,6 +382,7 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
|
||||
export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||
const translate = useTranslate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const notify = useNotify();
|
||||
const isImage = mimetype && mimetype.startsWith("image/");
|
||||
|
||||
const openFileInNewTab = (blobURL: string) => {
|
||||
@@ -334,18 +403,31 @@ export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
document.body.removeChild(anchorElement);
|
||||
setTimeout(() => URL.revokeObjectURL(blobURL), 10);;
|
||||
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
|
||||
};
|
||||
|
||||
const handleFile = async (preview: boolean) => {
|
||||
setLoading(true);
|
||||
const response = await fetchAuthenticatedMedia(mxcURL, "original");
|
||||
const blob = await response.blob();
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
if (preview) {
|
||||
openFileInNewTab(blobURL);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
if (preview) {
|
||||
openFileInNewTab(blobURL);
|
||||
} else {
|
||||
downloadFile(blobURL);
|
||||
}
|
||||
} else {
|
||||
downloadFile(blobURL);
|
||||
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);
|
||||
};
|
||||
@@ -371,7 +453,7 @@ export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||
onClick={() => handleFile(false)}
|
||||
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||
>
|
||||
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||
</Button>
|
||||
<span>{label}</span>
|
||||
</Box>
|
||||
@@ -391,10 +473,18 @@ export const MediaIDField = ({ source }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
|
||||
const mxcURL = `mxc://${homeserver}/${mediaID}`;
|
||||
let uploadName = mediaID;
|
||||
if (get(record, "upload_name")) {
|
||||
uploadName = decodeURLComponent(get(record, "upload_name")?.toString());
|
||||
}
|
||||
|
||||
return <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||
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 <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type} />;
|
||||
};
|
||||
|
||||
export const ReportMediaContent = ({ source }) => {
|
||||
@@ -408,7 +498,10 @@ export const ReportMediaContent = ({ source }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploadName = decodeURIComponent(get(record, "event_json.content.body")?.toString());
|
||||
let uploadName = "";
|
||||
if (get(record, "event_json.content.body")) {
|
||||
uploadName = decodeURLComponent(get(record, "event_json.content.body")?.toString());
|
||||
}
|
||||
|
||||
return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||
return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type} />;
|
||||
};
|
||||
|
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;
|
@@ -12,9 +12,14 @@ const fixedGermanMessages = {
|
||||
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,
|
||||
@@ -24,13 +29,15 @@ const fixedGermanMessages = {
|
||||
},
|
||||
message: {
|
||||
...formalGermanMessages.ra.message,
|
||||
access_denied:
|
||||
"Sie haben nicht die erforderlichen Berechtigungen um auf diese Seite zuzugreifen.",
|
||||
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,
|
||||
@@ -48,14 +55,15 @@ const de: SynapseTranslationMessages = {
|
||||
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?",
|
||||
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: {
|
||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||
tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits" },
|
||||
tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits", account_data: "Kontodaten" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Raumdetails",
|
||||
@@ -64,6 +72,7 @@ const de: SynapseTranslationMessages = {
|
||||
members: "Mitglieder",
|
||||
detail: "Details",
|
||||
permission: "Berechtigungen",
|
||||
media: "Medien",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
|
||||
@@ -83,7 +92,7 @@ const de: SynapseTranslationMessages = {
|
||||
goToPdf: "Gehe zum PDF",
|
||||
cards: {
|
||||
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",
|
||||
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
|
||||
admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
|
||||
@@ -146,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.",
|
||||
},
|
||||
},
|
||||
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: {
|
||||
users: {
|
||||
name: "Benutzer",
|
||||
@@ -159,11 +182,13 @@ const de: SynapseTranslationMessages = {
|
||||
is_guest: "Gast",
|
||||
admin: "Server Administrator",
|
||||
locked: "Gesperrt",
|
||||
suspended: "Suspendiert",
|
||||
deactivated: "Deaktiviert",
|
||||
erased: "Gelöscht",
|
||||
guests: "Zeige Gäste",
|
||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||
show_locked: "Zeige gesperrte Benutzer",
|
||||
show_suspended: "Zeige suspendierte Benutzer",
|
||||
user_id: "Suche Benutzer",
|
||||
displayname: "Anzeigename",
|
||||
password: "Passwort",
|
||||
@@ -181,8 +206,11 @@ const de: SynapseTranslationMessages = {
|
||||
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.",
|
||||
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_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",
|
||||
@@ -202,7 +230,8 @@ const de: SynapseTranslationMessages = {
|
||||
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_content:
|
||||
"Dieser Benutzername ist bereits vergeben. Sind Sie sicher, dass Sie den vorhandenen Benutzer überschreiben möchten?",
|
||||
overwrite_cancel: "Abbrechen",
|
||||
overwrite_confirm: "Überschreiben",
|
||||
},
|
||||
@@ -211,7 +240,12 @@ const de: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Raum |||| Räume",
|
||||
@@ -274,10 +308,11 @@ const de: SynapseTranslationMessages = {
|
||||
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.",
|
||||
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}",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -429,6 +464,18 @@ const de: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Raumverzeichnis",
|
||||
fields: {
|
||||
|
@@ -18,7 +18,8 @@ const en: SynapseTranslationMessages = {
|
||||
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?",
|
||||
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",
|
||||
},
|
||||
@@ -29,6 +30,7 @@ const en: SynapseTranslationMessages = {
|
||||
sso: "SSO",
|
||||
experimental: "Experimental",
|
||||
limits: "Rate Limits",
|
||||
account_data: "Account Data",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@@ -38,7 +40,8 @@ const en: SynapseTranslationMessages = {
|
||||
members: "Members",
|
||||
detail: "Details",
|
||||
permission: "Permissions",
|
||||
}
|
||||
media: "Media",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Basic", detail: "Details" } },
|
||||
},
|
||||
@@ -56,7 +59,7 @@ const en: SynapseTranslationMessages = {
|
||||
goToPdf: "Go to PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Import users",
|
||||
header: "Parsed users for import",
|
||||
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
||||
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
||||
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
||||
@@ -119,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.",
|
||||
},
|
||||
},
|
||||
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: {
|
||||
users: {
|
||||
name: "User |||| Users",
|
||||
@@ -132,11 +149,13 @@ const en: SynapseTranslationMessages = {
|
||||
is_guest: "Guest",
|
||||
admin: "Server Administrator",
|
||||
locked: "Locked",
|
||||
suspended: "Suspended",
|
||||
deactivated: "Deactivated",
|
||||
erased: "Erased",
|
||||
guests: "Show guests",
|
||||
show_deactivated: "Show deactivated users",
|
||||
show_locked: "Show locked users",
|
||||
show_suspended: "Show suspended users",
|
||||
user_id: "Search user",
|
||||
displayname: "Displayname",
|
||||
password: "Password",
|
||||
@@ -154,8 +173,10 @@ const en: SynapseTranslationMessages = {
|
||||
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.",
|
||||
suspend: "Suspending user means they are put into a read-only mode.",
|
||||
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_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",
|
||||
@@ -184,7 +205,12 @@ const en: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Room |||| Rooms",
|
||||
@@ -247,10 +273,11 @@ const en: SynapseTranslationMessages = {
|
||||
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.",
|
||||
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}",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -401,6 +428,18 @@ const en: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Room directory",
|
||||
fields: {
|
||||
@@ -443,5 +482,23 @@ const en: SynapseTranslationMessages = {
|
||||
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;
|
||||
|
@@ -17,14 +17,15 @@ const fa: SynapseTranslationMessages = {
|
||||
access_token: "توکن دسترسی",
|
||||
logout_acces_token_dialog: {
|
||||
title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.",
|
||||
content: "آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||
content:
|
||||
"آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||
confirm: "نابودی جلسه",
|
||||
cancel: "فقط خروج از پنل مدیریت",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||
tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها" },
|
||||
tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها", account_data: "داده های کاربر" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -32,6 +33,7 @@ const fa: SynapseTranslationMessages = {
|
||||
members: "اعضا",
|
||||
detail: "جزئیات",
|
||||
permission: "مجوزها",
|
||||
media: "رسانه ها",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
|
||||
@@ -50,7 +52,7 @@ const fa: SynapseTranslationMessages = {
|
||||
goToPdf: "رفتن به PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "وارد کردن کاربران",
|
||||
header: "کاربران پردازش شده برای وارد کردن",
|
||||
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
|
||||
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
|
||||
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
|
||||
@@ -113,6 +115,20 @@ const fa: SynapseTranslationMessages = {
|
||||
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانههایی که در مخازن رسانه خارجی آپلود شدهاند تأثیری نخواهد گذاشت.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "رسانههای از راه دور",
|
||||
fields: {
|
||||
before_ts: "آخرین دسترسی قبل از",
|
||||
},
|
||||
action: {
|
||||
send: "پاک کردن رسانههای از راه دور",
|
||||
send_success: "درخواست پاک کردن رسانههای از راه دور ارسال شد.",
|
||||
send_failure: "درخواست برای پاک کردن رسانههای از راه دور با خطا مواجه شد.",
|
||||
},
|
||||
helper: {
|
||||
send: "این API کش رسانههای از راه دور را از دیسک سرور شما پاک میکند. این شامل هر گونه بندانگشتی محلی و نسخههای رسانههای دانلود شده میشود. این API بر رسانههای آپلود شده به مخزن رسانه سرور تأثیری نخواهد داشت.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "کاربر |||| کاربران",
|
||||
@@ -126,9 +142,12 @@ const fa: SynapseTranslationMessages = {
|
||||
is_guest: "مهمان",
|
||||
admin: "مدیر سرور",
|
||||
deactivated: "غیرفعال",
|
||||
locked: "قفل شده",
|
||||
suspended: "معلق",
|
||||
guests: "نمایش مهمانان",
|
||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||
show_locked: "نمایش کاربران قفل شده",
|
||||
show_suspended: "نمایش کاربران معلق",
|
||||
user_id: "جستجوی کاربر",
|
||||
displayname: "نام نمایشی",
|
||||
password: "رمز عبور",
|
||||
@@ -146,8 +165,10 @@ const fa: SynapseTranslationMessages = {
|
||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||
create_password: "رمز عبور قوی و امنی را با استفاده از دکمه زیر ایجاد کنید.",
|
||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||
suspend: "کاربران معلق نمی توانند وارد شوند و پیام های آنها به دیگران نمایش داده نمی شود.",
|
||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||
erase_text:
|
||||
"وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
|
||||
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
|
||||
username_available: "نام کاربری موجود",
|
||||
@@ -167,7 +188,8 @@ const fa: SynapseTranslationMessages = {
|
||||
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||
generate_password: "توليد رمز عبور",
|
||||
overwrite_title: "هشدار!",
|
||||
overwrite_content: "این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می خواهید کاربر موجود را بازنویسی کنید؟",
|
||||
overwrite_content:
|
||||
"این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می خواهید کاربر موجود را بازنویسی کنید؟",
|
||||
overwrite_cancel: "انصراف",
|
||||
overwrite_confirm: "بازنویسی",
|
||||
},
|
||||
@@ -176,7 +198,12 @@ const fa: SynapseTranslationMessages = {
|
||||
messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود.",
|
||||
burst_count: "تعداد پیچیدگی",
|
||||
burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.",
|
||||
}
|
||||
},
|
||||
account_data: {
|
||||
title: "داده های کاربر",
|
||||
global: "عمومی",
|
||||
rooms: "اتاق ها",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "اتاق |||| اتاق ها",
|
||||
@@ -239,10 +266,11 @@ const fa: SynapseTranslationMessages = {
|
||||
assign_admin: "مدیر انتخاب کنید",
|
||||
title: "مدیر اتاق %{roomName} را انتخاب کنید",
|
||||
confirm: "مدیر انتخاب کنید",
|
||||
content: "کامل MXID کاربری را وارد کنید که به عنوان مدیر تنظیم شود.\nهشدار: برای این کار، اتاق باید حداقل یک اعضای محلی به عنوان مدیر داشته باشد.",
|
||||
content:
|
||||
"کامل MXID کاربری را وارد کنید که به عنوان مدیر تنظیم شود.\nهشدار: برای این کار، اتاق باید حداقل یک اعضای محلی به عنوان مدیر داشته باشد.",
|
||||
success: "کاربر به عنوان مدیر اتاق تنظیم شد.",
|
||||
failure: "کاربر به عنوان مدیر اتاق تنظیم نشد. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -380,6 +408,18 @@ const fa: SynapseTranslationMessages = {
|
||||
sender: "فرستنده",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "رسانه ها",
|
||||
fields: {
|
||||
media_id: "شناسه رسانه",
|
||||
},
|
||||
helper: {
|
||||
info: "این یک لیست از رسانه ها است که در اتاق بارگذاری شده است. نمی توان رسانه ها را حذف کرد که در اتاق های خارجی بارگذاری شده اند.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "راهنمای اتاق",
|
||||
fields: {
|
||||
|
@@ -17,14 +17,20 @@ const fr: SynapseTranslationMessages = {
|
||||
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?",
|
||||
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: {
|
||||
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||
tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites" },
|
||||
tabs: {
|
||||
sso: "Authentification unique",
|
||||
experimental: "Expérimental",
|
||||
limits: "Limites",
|
||||
account_data: "Données du compte",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -32,6 +38,7 @@ const fr: SynapseTranslationMessages = {
|
||||
members: "Membres",
|
||||
detail: "Détails",
|
||||
permission: "Permissions",
|
||||
media: "Médias",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
|
||||
@@ -51,7 +58,7 @@ const fr: SynapseTranslationMessages = {
|
||||
goToPdf: "Voir le PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Importer des utilisateurs",
|
||||
header: "Utilisateurs analysés pour l'import",
|
||||
users_total:
|
||||
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
|
||||
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
|
||||
@@ -116,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.",
|
||||
},
|
||||
},
|
||||
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: {
|
||||
users: {
|
||||
name: "Utilisateur |||| Utilisateurs",
|
||||
@@ -129,10 +150,12 @@ const fr: SynapseTranslationMessages = {
|
||||
is_guest: "Visiteur",
|
||||
admin: "Administrateur du serveur",
|
||||
locked: "Verrouillé",
|
||||
suspended: "Suspendu",
|
||||
deactivated: "Désactivé",
|
||||
guests: "Afficher les visiteurs",
|
||||
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",
|
||||
displayname: "Nom d'affichage",
|
||||
password: "Mot de passe",
|
||||
@@ -149,8 +172,10 @@ const fr: SynapseTranslationMessages = {
|
||||
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.",
|
||||
suspend: "L'utilisateur sera suspendu jusqu'à ce que vous le réactiviez.",
|
||||
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_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",
|
||||
@@ -170,7 +195,8 @@ const fr: SynapseTranslationMessages = {
|
||||
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_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",
|
||||
},
|
||||
@@ -179,7 +205,12 @@ const fr: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Salon |||| Salons",
|
||||
@@ -242,10 +273,11 @@ const fr: SynapseTranslationMessages = {
|
||||
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.",
|
||||
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}",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -383,6 +415,18 @@ const fr: SynapseTranslationMessages = {
|
||||
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: {
|
||||
name: "Répertoire des salons",
|
||||
fields: {
|
||||
|
59
src/i18n/index.d.ts
vendored
@@ -22,7 +22,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
};
|
||||
users: {
|
||||
invalid_user_id: string;
|
||||
tabs: { sso: string; experimental: string; limits: string; };
|
||||
tabs: { sso: string; experimental: string; limits: string; account_data: string };
|
||||
};
|
||||
rooms: {
|
||||
details?: string; // TODO: fa, fr, it, zh
|
||||
@@ -31,6 +31,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
members: string;
|
||||
detail: string;
|
||||
permission: string;
|
||||
media: string;
|
||||
};
|
||||
};
|
||||
reports: { tabs: { basic: string; detail: string } };
|
||||
@@ -111,6 +112,20 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
send: string;
|
||||
};
|
||||
};
|
||||
purge_remote_media: {
|
||||
name: string;
|
||||
fields: {
|
||||
before_ts: string;
|
||||
};
|
||||
action: {
|
||||
send: string;
|
||||
send_success: string;
|
||||
send_failure: string;
|
||||
};
|
||||
helper: {
|
||||
send: string;
|
||||
};
|
||||
};
|
||||
resources: {
|
||||
users: {
|
||||
name: string;
|
||||
@@ -124,11 +139,13 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
is_guest: string;
|
||||
admin: string;
|
||||
locked?: string; // TODO: fa, zh
|
||||
suspended: string;
|
||||
deactivated: string;
|
||||
erased?: string; // TODO: fa, fr, it, zh
|
||||
guests: string;
|
||||
show_deactivated: string;
|
||||
show_locked?: string; // TODO: de, fa, fr, it, zh
|
||||
show_suspended: string;
|
||||
user_id: string;
|
||||
displayname: string;
|
||||
password: string;
|
||||
@@ -146,6 +163,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
password: string;
|
||||
create_password: string;
|
||||
deactivate: string;
|
||||
suspend: string;
|
||||
erase: string;
|
||||
erase_text: string;
|
||||
erase_admin_error: string;
|
||||
@@ -170,13 +188,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
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: {
|
||||
name: string;
|
||||
@@ -229,7 +252,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
content: string;
|
||||
fields: {
|
||||
block: string;
|
||||
},
|
||||
};
|
||||
success: string;
|
||||
failure: string;
|
||||
};
|
||||
@@ -393,6 +416,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
sender: string;
|
||||
};
|
||||
};
|
||||
room_media?: {
|
||||
name: string;
|
||||
fields: {
|
||||
media_id: string;
|
||||
};
|
||||
helper: {
|
||||
info: string;
|
||||
};
|
||||
action: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
room_directory?: {
|
||||
name: string;
|
||||
fields: {
|
||||
@@ -438,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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
433
src/i18n/it.ts
@@ -17,14 +17,15 @@ const it: SynapseTranslationMessages = {
|
||||
access_token: "Token di accesso",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Stai utilizzando un token di accesso Matrix esistente.",
|
||||
content: "Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?",
|
||||
content:
|
||||
"Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?",
|
||||
confirm: "Distruggi sessione",
|
||||
cancel: "Disconnetti solo dal pannello di amministrazione",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "ID utente non valido su questo homeserver.",
|
||||
tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti" },
|
||||
tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti", account_data: "Dati del profilo" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -32,6 +33,7 @@ const it: SynapseTranslationMessages = {
|
||||
members: "Membro",
|
||||
detail: "Dettagli",
|
||||
permission: "Permessi",
|
||||
media: "Media",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Semplice", detail: "Dettagli" } },
|
||||
@@ -50,7 +52,7 @@ const it: SynapseTranslationMessages = {
|
||||
goToPdf: "Vai al PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Importa utenti",
|
||||
header: "Utenti analizzati per l'importazione",
|
||||
users_total: "%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
|
||||
guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti",
|
||||
admin_count: "%{smart_count} amministratore |||| %{smart_count} amministratori",
|
||||
@@ -113,6 +115,20 @@ const it: SynapseTranslationMessages = {
|
||||
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Media Remoti",
|
||||
fields: {
|
||||
before_ts: "ultimo accesso prima di",
|
||||
},
|
||||
action: {
|
||||
send: "Elimina media remoti",
|
||||
send_success: "La richiesta per eliminare i media remoti è stata inviata.",
|
||||
send_failure: "Si è verificato un errore con la richiesta di eliminazione dei media remoti.",
|
||||
},
|
||||
helper: {
|
||||
send: "Questa API elimina la cache dei media remoti dal disco del tuo server. Questo include qualsiasi miniatura locale e copie di media scaricati. Questa API non influirà sui media che sono stati caricati nel repository multimediale del server.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Utente |||| Utenti",
|
||||
@@ -126,10 +142,12 @@ const it: SynapseTranslationMessages = {
|
||||
is_guest: "Ospite",
|
||||
admin: "Amministratore",
|
||||
locked: "Bloccato",
|
||||
suspended: "Sospeso",
|
||||
deactivated: "Disattivato",
|
||||
guests: "Mostra gli ospiti",
|
||||
show_deactivated: "Mostra gli utenti disattivati",
|
||||
show_locked: "Mostra gli utenti bloccati",
|
||||
show_suspended: "Mostra gli utenti sospesi",
|
||||
user_id: "Cerca utente",
|
||||
displayname: "Nickname",
|
||||
password: "Password",
|
||||
@@ -147,8 +165,10 @@ const it: SynapseTranslationMessages = {
|
||||
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
||||
create_password: "Genera una password forte e sicura utilizzando il pulsante sottostante.",
|
||||
deactivate: "Devi fornire una password per riattivare l'account.",
|
||||
suspend: "Sospendi l'utente",
|
||||
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
||||
erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
|
||||
erase_text:
|
||||
"Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
|
||||
erase_admin_error: "Non è consentito eliminare il proprio utente.",
|
||||
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
|
||||
username_available: "Nome utente disponibile",
|
||||
@@ -168,7 +188,8 @@ const it: SynapseTranslationMessages = {
|
||||
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
|
||||
generate_password: "Genera password",
|
||||
overwrite_title: "Attenzione!",
|
||||
overwrite_content: "Questo nome utente è già stato utilizzato. Sei sicuro di voler sovrascrivere l'utente esistente?",
|
||||
overwrite_content:
|
||||
"Questo nome utente è già stato utilizzato. Sei sicuro di voler sovrascrivere l'utente esistente?",
|
||||
overwrite_cancel: "Annulla",
|
||||
overwrite_confirm: "Sovrascrivi",
|
||||
},
|
||||
@@ -177,7 +198,12 @@ const it: SynapseTranslationMessages = {
|
||||
messages_per_second_text: "Il numero di azioni che l'utente può eseguire al secondo.",
|
||||
burst_count: "Burst-conteggio",
|
||||
burst_count_text: "Il numero di azioni che l'utente può eseguire prima di essere limitato.",
|
||||
}
|
||||
},
|
||||
account_data: {
|
||||
title: "Dati del profilo",
|
||||
global: "Globale",
|
||||
rooms: "Stanza",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "Stanza |||| Stanze",
|
||||
@@ -205,218 +231,231 @@ const it: SynapseTranslationMessages = {
|
||||
helper: {
|
||||
/* 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.", */
|
||||
},
|
||||
enums: {
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
public: "Pubblica",
|
||||
knock: "Bussa",
|
||||
invite: "Invita",
|
||||
private: "Privata",
|
||||
public: "Pubblica",
|
||||
knock: "Bussa",
|
||||
invite: "Invita",
|
||||
private: "Privata",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Gli utenti ospiti possono entrare",
|
||||
forbidden: "Gli utenti ospiti non possono entrare",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "Dall'invito",
|
||||
joined: "Dall'entrata",
|
||||
shared: "Dalla condivisione",
|
||||
world_readable: "Chiunque",
|
||||
},
|
||||
unencrypted: "Non criptata",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Gli utenti ospiti possono entrare",
|
||||
forbidden: "Gli utenti ospiti non possono entrare",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "Dall'invito",
|
||||
joined: "Dall'entrata",
|
||||
shared: "Dalla condivisione",
|
||||
world_readable: "Chiunque",
|
||||
},
|
||||
unencrypted: "Non criptata",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Cancella stanza",
|
||||
content:
|
||||
"Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Assegna un amministratore",
|
||||
title: "Assegna un amministratore alla stanza %{roomName}",
|
||||
confirm: "Assegna un amministratore",
|
||||
content: "Inserisci la MXID completa dell'utente che sarà designato come amministratore.\nAttenzione: per questo funzionare, la stanza deve avere almeno un membro locale come amministratore.",
|
||||
success: "L'utente è stato designato come amministratore della stanza.",
|
||||
failure: "L'utente non può essere designato come amministratore della stanza. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Evento segnalato |||| Eventi segnalati",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "Orario del report",
|
||||
user_id: "richiedente",
|
||||
name: "nome della stanza",
|
||||
score: "punteggio",
|
||||
reason: "ragione",
|
||||
event_id: "ID dell'evento",
|
||||
event_json: {
|
||||
origin: "server di origine",
|
||||
origin_server_ts: "ora dell'invio",
|
||||
type: "tipo di evento",
|
||||
content: {
|
||||
msgtype: "tipo di contenuto",
|
||||
body: "contenuto",
|
||||
format: "formato",
|
||||
formatted_body: "contenuto formattato",
|
||||
algorithm: "algoritmo",
|
||||
action: {
|
||||
erase: {
|
||||
title: "Cancella stanza",
|
||||
content:
|
||||
"Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Assegna un amministratore",
|
||||
title: "Assegna un amministratore alla stanza %{roomName}",
|
||||
confirm: "Assegna un amministratore",
|
||||
content:
|
||||
"Inserisci la MXID completa dell'utente che sarà designato come amministratore.\nAttenzione: per questo funzionare, la stanza deve avere almeno un membro locale come amministratore.",
|
||||
success: "L'utente è stato designato come amministratore della stanza.",
|
||||
failure: "L'utente non può essere designato come amministratore della stanza. %{errMsg}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
name: "Connessioni",
|
||||
fields: {
|
||||
last_seen: "Data",
|
||||
ip: "Indirizzo IP",
|
||||
user_agent: "agente utente",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "Dispositivo |||| Dispositivi",
|
||||
fields: {
|
||||
device_id: "ID del dispositivo",
|
||||
display_name: "Nome del dispositivo",
|
||||
last_seen_ts: "Timestamp",
|
||||
last_seen_ip: "Indirizzo IP",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Rimozione del dispositivo %{id}",
|
||||
content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?',
|
||||
success: "Dispositivo rimosso con successo.",
|
||||
failure: "C'è stato un errore.",
|
||||
reports: {
|
||||
name: "Evento segnalato |||| Eventi segnalati",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "Orario del report",
|
||||
user_id: "richiedente",
|
||||
name: "nome della stanza",
|
||||
score: "punteggio",
|
||||
reason: "ragione",
|
||||
event_id: "ID dell'evento",
|
||||
event_json: {
|
||||
origin: "server di origine",
|
||||
origin_server_ts: "ora dell'invio",
|
||||
type: "tipo di evento",
|
||||
content: {
|
||||
msgtype: "tipo di contenuto",
|
||||
body: "contenuto",
|
||||
format: "formato",
|
||||
formatted_body: "contenuto formattato",
|
||||
algorithm: "algoritmo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "ID del media",
|
||||
media_length: "Peso del file (in Byte)",
|
||||
media_type: "Tipo",
|
||||
upload_name: "Nome del file",
|
||||
quarantined_by: "In quarantena da",
|
||||
safe_from_quarantine: "Protetto dalla quarantena",
|
||||
created_ts: "Creato",
|
||||
last_access_ts: "Ultimo accesso",
|
||||
connections: {
|
||||
name: "Connessioni",
|
||||
fields: {
|
||||
last_seen: "Data",
|
||||
ip: "Indirizzo IP",
|
||||
user_agent: "agente utente",
|
||||
},
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Non protetto, proteggi",
|
||||
delete: "Protetto, rimuovi protezione",
|
||||
none: "In quarantena",
|
||||
send_success: "Stato della protezione cambiato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
devices: {
|
||||
name: "Dispositivo |||| Dispositivi",
|
||||
fields: {
|
||||
device_id: "ID del dispositivo",
|
||||
display_name: "Nome del dispositivo",
|
||||
last_seen_ts: "Timestamp",
|
||||
last_seen_ip: "Indirizzo IP",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Rimozione del dispositivo %{id}",
|
||||
content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?',
|
||||
success: "Dispositivo rimosso con successo.",
|
||||
failure: "C'è stato un errore.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Quarantina",
|
||||
create: "Aggiungi alla quarantena",
|
||||
delete: "In quarantena, rimuovi dalla quarantena",
|
||||
none: "Protetto dalla quarantena",
|
||||
send_success: "Stato della quarantena cambiato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
users_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "ID del media",
|
||||
media_length: "Peso del file (in Byte)",
|
||||
media_type: "Tipo",
|
||||
upload_name: "Nome del file",
|
||||
quarantined_by: "In quarantena da",
|
||||
safe_from_quarantine: "Protetto dalla quarantena",
|
||||
created_ts: "Creato",
|
||||
last_access_ts: "Ultimo accesso",
|
||||
},
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "Pusher |||| Pusher",
|
||||
fields: {
|
||||
app: "App",
|
||||
app_display_name: "Nome dell'app",
|
||||
app_id: "ID dell'app",
|
||||
device_display_name: "Nome del dispositivo",
|
||||
kind: "Tipo",
|
||||
lang: "Lingua",
|
||||
profile_tag: "Tag del profilo",
|
||||
pushkey: "Pushkey",
|
||||
data: { url: "URL" },
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Non protetto, proteggi",
|
||||
delete: "Protetto, rimuovi protezione",
|
||||
none: "In quarantena",
|
||||
send_success: "Stato della protezione cambiato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
},
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "Avvisi del server",
|
||||
send: "Invia avvisi",
|
||||
fields: {
|
||||
body: "Messaggio",
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Quarantina",
|
||||
create: "Aggiungi alla quarantena",
|
||||
delete: "In quarantena, rimuovi dalla quarantena",
|
||||
none: "Protetto dalla quarantena",
|
||||
send_success: "Stato della quarantena cambiato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
send: "Invia nota",
|
||||
send_success: "Avviso inviato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
pushers: {
|
||||
name: "Pusher |||| Pusher",
|
||||
fields: {
|
||||
app: "App",
|
||||
app_display_name: "Nome dell'app",
|
||||
app_id: "ID dell'app",
|
||||
device_display_name: "Nome del dispositivo",
|
||||
kind: "Tipo",
|
||||
lang: "Lingua",
|
||||
profile_tag: "Tag del profilo",
|
||||
pushkey: "Pushkey",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
helper: {
|
||||
send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.',
|
||||
servernotices: {
|
||||
name: "Avvisi del server",
|
||||
send: "Invia avvisi",
|
||||
fields: {
|
||||
body: "Messaggio",
|
||||
},
|
||||
action: {
|
||||
send: "Invia nota",
|
||||
send_success: "Avviso inviato con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
},
|
||||
helper: {
|
||||
send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.',
|
||||
},
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "Media degli utenti",
|
||||
fields: {
|
||||
media_count: "Numero media",
|
||||
media_length: "Lunghezza media",
|
||||
user_media_statistics: {
|
||||
name: "Media degli utenti",
|
||||
fields: {
|
||||
media_count: "Numero media",
|
||||
media_length: "Lunghezza media",
|
||||
},
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Invia estremità",
|
||||
fields: {
|
||||
id: "Event ID",
|
||||
received_ts: "Timestamp",
|
||||
depth: "Profondità",
|
||||
state_group: "State group",
|
||||
forward_extremities: {
|
||||
name: "Invia estremità",
|
||||
fields: {
|
||||
id: "Event ID",
|
||||
received_ts: "Timestamp",
|
||||
depth: "Profondità",
|
||||
state_group: "State group",
|
||||
},
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "Eventi di stato",
|
||||
fields: {
|
||||
type: "Tipo",
|
||||
content: "Contenuto",
|
||||
origin_server_ts: "Ora dell'invio",
|
||||
sender: "Mittente",
|
||||
room_state: {
|
||||
name: "Eventi di stato",
|
||||
fields: {
|
||||
type: "Tipo",
|
||||
content: "Contenuto",
|
||||
origin_server_ts: "Ora dell'invio",
|
||||
sender: "Mittente",
|
||||
},
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Elenco delle stanze",
|
||||
fields: {
|
||||
world_readable: "gli utenti ospite possono vedere senza entrare",
|
||||
guest_can_join: "gli utenti ospite possono entrare",
|
||||
room_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "ID Media",
|
||||
},
|
||||
helper: {
|
||||
info: "Questo è un elenco dei media caricati nella stanza. Non è possibile eliminare i media caricati su repository esterni.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
|
||||
content:
|
||||
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
|
||||
erase: "Rimuovi dall'elenco",
|
||||
create: "Crea",
|
||||
send_success: "Stanza creata con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
room_directory: {
|
||||
name: "Elenco delle stanze",
|
||||
fields: {
|
||||
world_readable: "gli utenti ospite possono vedere senza entrare",
|
||||
guest_can_join: "gli utenti ospite possono entrare",
|
||||
},
|
||||
action: {
|
||||
title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
|
||||
content:
|
||||
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
|
||||
erase: "Rimuovi dall'elenco",
|
||||
create: "Crea",
|
||||
send_success: "Stanza creata con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
},
|
||||
},
|
||||
},
|
||||
destinations: {
|
||||
name: "Federazione",
|
||||
fields: {
|
||||
destination: "Destinazione",
|
||||
failure_ts: "Timestamp dell'errore",
|
||||
retry_last_ts: "Tentativo ultimo timestamp",
|
||||
retry_interval: "Intervallo dei tentativi",
|
||||
last_successful_stream_ordering: "Ultimo flusso riuscito con successo",
|
||||
stream_ordering: "Flusso",
|
||||
destinations: {
|
||||
name: "Federazione",
|
||||
fields: {
|
||||
destination: "Destinazione",
|
||||
failure_ts: "Timestamp dell'errore",
|
||||
retry_last_ts: "Tentativo ultimo timestamp",
|
||||
retry_interval: "Intervallo dei tentativi",
|
||||
last_successful_stream_ordering: "Ultimo flusso riuscito con successo",
|
||||
stream_ordering: "Flusso",
|
||||
},
|
||||
action: { reconnect: "Riconnetti" },
|
||||
},
|
||||
action: { reconnect: "Riconnetti" },
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "Token di registrazione",
|
||||
fields: {
|
||||
token: "Token",
|
||||
valid: "Token valido",
|
||||
uses_allowed: "Usi permessi",
|
||||
pending: "In attesa",
|
||||
completed: "Completato",
|
||||
expiry_time: "Data della scadenza",
|
||||
length: "Lunghezza",
|
||||
registration_tokens: {
|
||||
name: "Token di registrazione",
|
||||
fields: {
|
||||
token: "Token",
|
||||
valid: "Token valido",
|
||||
uses_allowed: "Usi permessi",
|
||||
pending: "In attesa",
|
||||
completed: "Completato",
|
||||
expiry_time: "Data della scadenza",
|
||||
length: "Lunghezza",
|
||||
},
|
||||
helper: { length: "Lunghezza del token se non viene dato alcun token." },
|
||||
},
|
||||
helper: { length: "Lunghezza del token se non viene dato alcun token." },
|
||||
},
|
||||
},
|
||||
};
|
||||
export default it;
|
||||
|
@@ -11,6 +11,10 @@ const fixedRussianMessages = {
|
||||
no_filtered_results: "Нет результатов",
|
||||
clear_filters: "Все фильтры сбросить",
|
||||
},
|
||||
action: {
|
||||
...russianMessages.ra.action,
|
||||
select_all_button: "Выбрать все",
|
||||
},
|
||||
page: {
|
||||
...russianMessages.ra.page,
|
||||
empty: "Пусто",
|
||||
@@ -19,13 +23,12 @@ const fixedRussianMessages = {
|
||||
},
|
||||
message: {
|
||||
...russianMessages.ra.message,
|
||||
access_denied:
|
||||
"У вас нет прав доступа к этой странице.",
|
||||
authentication_error:
|
||||
"Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.",
|
||||
access_denied: "У вас нет прав доступа к этой странице.",
|
||||
authentication_error: "Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.",
|
||||
select_all_limit_reached: "Слишком много элементов для выбора. Были выбраны только первые %{max} элементов.",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
...fixedRussianMessages,
|
||||
@@ -43,14 +46,20 @@ const ru: SynapseTranslationMessages = {
|
||||
access_token: "Токен доступа",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Вы используете существующий токен доступа Matrix.",
|
||||
content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?",
|
||||
content:
|
||||
"Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?",
|
||||
confirm: "Завершить сессию",
|
||||
cancel: "Просто выйти из панели администрирования",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
|
||||
tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения" },
|
||||
tabs: {
|
||||
sso: "SSO",
|
||||
experimental: "Экспериментальные",
|
||||
limits: "Ограничения",
|
||||
account_data: "Данные пользователя",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
details: "Данные комнаты",
|
||||
@@ -59,6 +68,7 @@ const ru: SynapseTranslationMessages = {
|
||||
members: "Участники",
|
||||
detail: "Подробности",
|
||||
permission: "Права доступа",
|
||||
media: "Медиа",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Основные", detail: "Подробности" } },
|
||||
@@ -77,7 +87,7 @@ const ru: SynapseTranslationMessages = {
|
||||
goToPdf: "Перейти к PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Импорт пользователей",
|
||||
header: "Анализированные пользователи для импорта",
|
||||
users_total:
|
||||
"%{smart_count} пользователь в CSV файле |||| %{smart_count} пользователя в CSV файле |||| %{smart_count} пользователей в CSV файле",
|
||||
guest_count: "%{smart_count} гость |||| %{smart_count} гостя |||| %{smart_count} гостей",
|
||||
@@ -149,6 +159,20 @@ const ru: SynapseTranslationMessages = {
|
||||
Данный API не затрагивает файлы, загруженные во внешние хранилища.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Внешние медиа",
|
||||
fields: {
|
||||
before_ts: "последний доступ до",
|
||||
},
|
||||
action: {
|
||||
send: "Очистить внешние медиа",
|
||||
send_success: "Запрос на очистку внешних медиа был отправлен.",
|
||||
send_failure: "Произошла ошибка при запросе очистки внешних медиа.",
|
||||
},
|
||||
helper: {
|
||||
send: "Этот API очищает кэш внешних медиа с диска вашего сервера. Это включает любые локальные миниатюры и копии загруженных медиа. Этот API не повлияет на медиа, которые были загружены в собственное медиа-хранилище сервера.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Пользователь |||| Пользователи",
|
||||
@@ -162,11 +186,13 @@ const ru: SynapseTranslationMessages = {
|
||||
is_guest: "Гость",
|
||||
admin: "Администратор сервера",
|
||||
locked: "Заблокирован",
|
||||
suspended: "Приостановлен",
|
||||
deactivated: "Деактивирован",
|
||||
erased: "Удалён",
|
||||
guests: "Показывать гостей",
|
||||
show_deactivated: "Показывать деактивированных",
|
||||
show_locked: "Показывать заблокированных",
|
||||
show_suspended: "Показывать приостановленных",
|
||||
user_id: "Поиск пользователя",
|
||||
displayname: "Отображаемое имя",
|
||||
password: "Пароль",
|
||||
@@ -184,8 +210,11 @@ const ru: SynapseTranslationMessages = {
|
||||
password: "Смена пароля завершит все сессии пользователя.",
|
||||
create_password: "Сгенерировать надёжный и безопасный пароль, используя кнопку ниже.",
|
||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||
suspend:
|
||||
"Приостановка учётной записи означает, что пользователь не сможет войти в свою учётную запись, пока она не будет снова активирована.",
|
||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
|
||||
erase_text:
|
||||
"Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
|
||||
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
|
||||
username_available: "Имя пользователя доступно",
|
||||
@@ -205,7 +234,8 @@ const ru: SynapseTranslationMessages = {
|
||||
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
|
||||
generate_password: "Сгенерировать пароль",
|
||||
overwrite_title: "Предупреждение!",
|
||||
overwrite_content: "Это имя пользователя уже занято. Вы уверены, что хотите перезаписать существующего пользователя?",
|
||||
overwrite_content:
|
||||
"Это имя пользователя уже занято. Вы уверены, что хотите перезаписать существующего пользователя?",
|
||||
overwrite_cancel: "Отмена",
|
||||
overwrite_confirm: "Перезаписать",
|
||||
},
|
||||
@@ -214,7 +244,12 @@ const ru: SynapseTranslationMessages = {
|
||||
messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду.",
|
||||
burst_count: "Burst-счётчик",
|
||||
burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.",
|
||||
}
|
||||
},
|
||||
account_data: {
|
||||
title: "Данные пользователя",
|
||||
global: "Глобальные",
|
||||
rooms: "Комнаты",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "Комната |||| Комнаты",
|
||||
@@ -280,10 +315,11 @@ const ru: SynapseTranslationMessages = {
|
||||
assign_admin: "Назначить администратора",
|
||||
title: "Назначить администратора комнате %{roomName}",
|
||||
confirm: "Назначить администратора",
|
||||
content: "Введите полную MXID пользователя, которого нужно назначить администратором.\nПредупреждение: для этого должен быть назначен хотя бы один локальный участник в качестве администратора.",
|
||||
content:
|
||||
"Введите полную MXID пользователя, которого нужно назначить администратором.\nПредупреждение: для этого должен быть назначен хотя бы один локальный участник в качестве администратора.",
|
||||
success: "Пользователь назначен администратором комнаты.",
|
||||
failure: "Пользователь не может быть назначен администратором комнаты. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -434,6 +470,18 @@ const ru: SynapseTranslationMessages = {
|
||||
sender: "Отправитель",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "Медиа",
|
||||
fields: {
|
||||
media_id: "ID медиа",
|
||||
},
|
||||
helper: {
|
||||
info: "Это список медиа, которые были загружены в комнату. Невозможно удалить медиа, которые были загружены в внешние медиа-репозитории.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Каталог комнат",
|
||||
fields: {
|
||||
|
@@ -11,9 +11,14 @@ const fixedChineseMessages = {
|
||||
no_filtered_results: "没有结果",
|
||||
clear_filters: "清除所有过滤器",
|
||||
},
|
||||
auth: {
|
||||
...chineseMessages.ra.auth,
|
||||
email: "邮箱",
|
||||
},
|
||||
action: {
|
||||
...chineseMessages.ra.action,
|
||||
update_application: "Anwendung aktualisieren",
|
||||
update_application: "更新应用",
|
||||
select_all_button: "全部选择",
|
||||
},
|
||||
page: {
|
||||
...chineseMessages.ra.page,
|
||||
@@ -22,13 +27,13 @@ const fixedChineseMessages = {
|
||||
},
|
||||
message: {
|
||||
...chineseMessages.ra.message,
|
||||
access_denied:
|
||||
"您没有访问此页面的权限。",
|
||||
authentication_error:
|
||||
"身份验证服务器返回错误,无法验证您的凭据。",
|
||||
access_denied: "您没有访问此页面的权限。",
|
||||
authentication_error: "身份验证服务器返回错误,无法验证您的凭据。",
|
||||
select_all_limit_reached: "选择的元素太多。只选择了前 %{max} 个元素。",
|
||||
placeholder_data_warning: "网络问题:数据刷新失败。",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
...fixedChineseMessages,
|
||||
@@ -52,7 +57,7 @@ const zh: SynapseTranslationMessages = {
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
||||
tabs: { sso: "SSO", experimental: "实验性", limits: "限制" },
|
||||
tabs: { sso: "SSO", experimental: "实验性", limits: "限制", account_data: "账户数据" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -60,6 +65,7 @@ const zh: SynapseTranslationMessages = {
|
||||
members: "成员",
|
||||
detail: "细节",
|
||||
permission: "权限",
|
||||
media: "媒体",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "基本", detail: "细节" } },
|
||||
@@ -78,7 +84,7 @@ const zh: SynapseTranslationMessages = {
|
||||
goToPdf: "转到 PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "导入用户",
|
||||
header: "分析用于导入的用户",
|
||||
users_total: "%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
|
||||
guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客",
|
||||
admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员",
|
||||
@@ -141,6 +147,20 @@ const zh: SynapseTranslationMessages = {
|
||||
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "远程媒体",
|
||||
fields: {
|
||||
before_ts: "最后访问于之前",
|
||||
},
|
||||
action: {
|
||||
send: "清除远程媒体",
|
||||
send_success: "远程媒体清除请求已发送。",
|
||||
send_failure: "发生错误,远程媒体清除请求未成功。",
|
||||
},
|
||||
helper: {
|
||||
send: "此API清除您服务器磁盘上的远程媒体缓存。这包括任何本地缩略图和下载的媒体副本。此API不会影响已经上传到服务器媒体存储库的媒体。",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "用户",
|
||||
@@ -153,10 +173,13 @@ const zh: SynapseTranslationMessages = {
|
||||
name: "用户名",
|
||||
is_guest: "访客",
|
||||
admin: "服务器管理员",
|
||||
locked: "锁定",
|
||||
deactivated: "被禁用",
|
||||
suspended: "被锁定",
|
||||
guests: "显示访客",
|
||||
show_deactivated: "显示被禁用的账户",
|
||||
show_locked: "显示被锁定的账户",
|
||||
show_suspended: "显示被锁定的账户",
|
||||
user_id: "搜索用户",
|
||||
displayname: "显示名字",
|
||||
password: "密码",
|
||||
@@ -172,8 +195,10 @@ const zh: SynapseTranslationMessages = {
|
||||
password: "更改密码会使用户注销所有会话。",
|
||||
create_password: "使用下面的按钮生成一个强大和安全的密码。",
|
||||
deactivate: "您必须提供一串密码来激活账户。",
|
||||
suspend: "您必须提供一串密码来暂停账户。",
|
||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
|
||||
erase_text:
|
||||
"这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
|
||||
erase_admin_error: "不允许删除自己的用户",
|
||||
modify_managed_user_error: "不允许修改系统管理的用户。",
|
||||
username_available: "用户名可用",
|
||||
@@ -202,7 +227,12 @@ const zh: SynapseTranslationMessages = {
|
||||
messages_per_second_text: "每秒可以执行的操作数。",
|
||||
burst_count: "Burst-计数",
|
||||
burst_count_text: "在限制之前可以执行的操作数。",
|
||||
}
|
||||
},
|
||||
account_data: {
|
||||
title: "账户数据",
|
||||
global: "全局",
|
||||
rooms: "房间",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "房间",
|
||||
@@ -344,6 +374,18 @@ const zh: SynapseTranslationMessages = {
|
||||
media_length: "媒体文件长度",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "媒体",
|
||||
fields: {
|
||||
media_id: "媒体ID",
|
||||
},
|
||||
helper: {
|
||||
info: "这是上传到房间的媒体列表。无法删除上传到外部媒体存储库的媒体。",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default zh;
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import {App, AppContext } from "./App";
|
||||
import { App } from "./App";
|
||||
import { AppContext } from "./Context";
|
||||
import { FetchConfig, GetConfig } from "./utils/config";
|
||||
|
||||
await FetchConfig();
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<AppContext.Provider value={GetConfig()}>
|
||||
<App />
|
||||
</AppContext.Provider>
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<AppContext.Provider value={GetConfig()}>
|
||||
<App />
|
||||
</AppContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
import { AdminContext } from "react-admin";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import LoginPage from "./LoginPage";
|
||||
import { AppContext } from "../App";
|
||||
import { AppContext } from "../Context";
|
||||
import englishMessages from "../i18n/en";
|
||||
|
||||
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
||||
@@ -14,9 +14,11 @@ describe("LoginForm", () => {
|
||||
it("renders with no restriction to homeserver", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
<BrowserRouter>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
</BrowserRouter>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,11 +35,20 @@ describe("LoginForm", () => {
|
||||
|
||||
it("renders with single restricted homeserver", () => {
|
||||
render(
|
||||
<AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com" }}>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
</AppContext.Provider>
|
||||
<BrowserRouter>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
restrictBaseUrl: "https://matrix.example.com",
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
corsCredentials: "include",
|
||||
}}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
</AppContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
||||
@@ -56,10 +67,15 @@ describe("LoginForm", () => {
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"],
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
corsCredentials: "include",
|
||||
}}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
<BrowserRouter>
|
||||
<LoginPage />
|
||||
</BrowserRouter>
|
||||
</AdminContext>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
@@ -1,6 +1,17 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography } from "@mui/material";
|
||||
import {
|
||||
Form,
|
||||
FormDataConsumer,
|
||||
@@ -15,8 +26,10 @@ import {
|
||||
useLocales,
|
||||
} from "react-admin";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useAppContext } from "../Context";
|
||||
import Footer from "../components/Footer";
|
||||
import LoginFormBox from "../components/LoginFormBox";
|
||||
import { useAppContext } from "../App";
|
||||
import {
|
||||
getServerVersion,
|
||||
getSupportedFeatures,
|
||||
@@ -25,7 +38,6 @@ import {
|
||||
isValidBaseUrl,
|
||||
splitMxid,
|
||||
} from "../synapse/matrix";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
export type LoginMethod = "credentials" | "accessToken";
|
||||
|
||||
@@ -47,7 +59,7 @@ const LoginPage = () => {
|
||||
const translate = useTranslate();
|
||||
const base_url = allowSingleBaseUrl ? restrictBaseUrl : localStorage.getItem("base_url");
|
||||
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
||||
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
||||
const loginToken = new URLSearchParams(window.location.search).get("loginToken");
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,10 +67,11 @@ const LoginPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ssoToken = loginToken[1];
|
||||
console.log("SSO token is", ssoToken);
|
||||
console.log("SSO token is", loginToken);
|
||||
// Prevent further requests
|
||||
window.history.replaceState({}, "", window.location.href.replace(loginToken[0], "#").split("#")[0]);
|
||||
const previousUrl = new URL(window.location.toString());
|
||||
previousUrl.searchParams.delete("loginToken");
|
||||
window.history.replaceState({}, "", previousUrl.toString());
|
||||
const baseUrl = localStorage.getItem("sso_base_url");
|
||||
localStorage.removeItem("sso_base_url");
|
||||
if (baseUrl) {
|
||||
@@ -66,10 +79,10 @@ const LoginPage = () => {
|
||||
base_url: baseUrl,
|
||||
username: null,
|
||||
password: null,
|
||||
loginToken: ssoToken,
|
||||
loginToken,
|
||||
};
|
||||
console.log("Base URL is:", baseUrl);
|
||||
console.log("SSO Token is:", ssoToken);
|
||||
console.log("SSO Token is:", loginToken);
|
||||
console.log("Let's try token login...");
|
||||
login(auth).catch(error => {
|
||||
alert(
|
||||
@@ -171,11 +184,24 @@ const LoginPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hostname = window.location.hostname;
|
||||
const username = params.get("username");
|
||||
const password = params.get("password");
|
||||
const accessToken = params.get("accessToken");
|
||||
let serverURL = params.get("server");
|
||||
if (username) {
|
||||
form.setValue("username", username);
|
||||
}
|
||||
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
if (password) {
|
||||
form.setValue("password", password);
|
||||
}
|
||||
if (accessToken) {
|
||||
setLoginMethod("accessToken");
|
||||
form.setValue("accessToken", accessToken);
|
||||
}
|
||||
}
|
||||
if (serverURL) {
|
||||
const isFullUrl = serverURL.match(/^(http|https):\/\//);
|
||||
if (!isFullUrl) {
|
||||
@@ -267,7 +293,7 @@ const LoginPage = () => {
|
||||
{loading ? (
|
||||
<CircularProgress size={25} thickness={2} />
|
||||
) : (
|
||||
<Avatar sx={{ width: "120px", height: "120px" }} src="./images/logo.webp"/>
|
||||
<Avatar sx={{ width: "120px", height: "120px" }} src="./images/logo.webp" />
|
||||
)}
|
||||
</Box>
|
||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||
@@ -286,37 +312,35 @@ const LoginPage = () => {
|
||||
))}
|
||||
</Select>
|
||||
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
|
||||
{loginMethod === "credentials" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading || !supportPassAuth}
|
||||
fullWidth
|
||||
>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={handleSSO}
|
||||
disabled={loading || ssoBaseUrl === ""}
|
||||
fullWidth
|
||||
>
|
||||
{translate("synapseadmin.auth.sso_sign_in")}
|
||||
</Button>
|
||||
</CardActions>}
|
||||
{loginMethod === "accessToken" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
</CardActions>}
|
||||
{loginMethod === "credentials" && (
|
||||
<CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading || !supportPassAuth}
|
||||
fullWidth
|
||||
>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={handleSSO}
|
||||
disabled={loading || ssoBaseUrl === ""}
|
||||
fullWidth
|
||||
>
|
||||
{translate("synapseadmin.auth.sso_sign_in")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
{loginMethod === "accessToken" && (
|
||||
<CardActions className="actions">
|
||||
<Button variant="contained" type="submit" color="primary" disabled={loading} fullWidth>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</LoginFormBox>
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import DestinationsIcon from "@mui/icons-material/CloudQueue";
|
||||
import ErrorIcon from "@mui/icons-material/Error";
|
||||
import FolderSharedIcon from "@mui/icons-material/FolderShared";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import { get } from "lodash";
|
||||
import { MouseEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Datagrid,
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import { get } from "lodash";
|
||||
|
||||
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
@@ -101,19 +100,18 @@ const RetryDateField = (props: DateFieldProps) => {
|
||||
return <DateField {...props} />;
|
||||
};
|
||||
|
||||
|
||||
const destinationFieldRender = (record: RaRecord) => {
|
||||
if (record.retry_last_ts > 0) {
|
||||
return (
|
||||
<>
|
||||
<ErrorIcon fontSize="inherit" color="error" sx={{verticalAlign: "middle"}}/>
|
||||
<ErrorIcon fontSize="inherit" color="error" sx={{ verticalAlign: "middle" }} />
|
||||
|
||||
{record.destination}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <> {record.destination} </>;
|
||||
}
|
||||
};
|
||||
|
||||
export const DestinationList = (props: ListProps) => {
|
||||
const record = useRecordContext(props);
|
||||
@@ -123,13 +121,31 @@ export const DestinationList = (props: ListProps) => {
|
||||
filters={destinationFilters}
|
||||
pagination={<DestinationPagination />}
|
||||
sort={{ field: "destination", order: "ASC" }}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
||||
<FunctionField source="destination" render={destinationFieldRender} label="resources.destinations.fields.destination" />
|
||||
<DateField source="failure_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.failure_ts" />
|
||||
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.retry_last_ts" />
|
||||
<FunctionField
|
||||
source="destination"
|
||||
render={destinationFieldRender}
|
||||
label="resources.destinations.fields.destination"
|
||||
/>
|
||||
<DateField
|
||||
source="failure_ts"
|
||||
showTime
|
||||
options={DATE_FORMAT}
|
||||
label="resources.destinations.fields.failure_ts"
|
||||
/>
|
||||
<RetryDateField
|
||||
source="retry_last_ts"
|
||||
showTime
|
||||
options={DATE_FORMAT}
|
||||
label="resources.destinations.fields.retry_last_ts"
|
||||
/>
|
||||
<TextField source="retry_interval" label="resources.destinations.fields.retry_interval" />
|
||||
<TextField source="last_successful_stream_ordering" label="resources.destinations.fields.last_successful_stream_ordering" />
|
||||
<TextField
|
||||
source="last_successful_stream_ordering"
|
||||
label="resources.destinations.fields.last_successful_stream_ordering"
|
||||
/>
|
||||
<DestinationReconnectButton />
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
|
@@ -38,14 +38,20 @@ export const RegistrationTokenList = (props: ListProps) => (
|
||||
filters={registrationTokenFilters}
|
||||
filterDefaultValues={{ valid: true }}
|
||||
pagination={false}
|
||||
perPage={500}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable rowClick="edit">
|
||||
<TextField source="token" sortable={false} label="resources.registration_tokens.fields.token"/>
|
||||
<TextField source="token" sortable={false} label="resources.registration_tokens.fields.token" />
|
||||
<NumberField source="uses_allowed" sortable={false} label="resources.registration_tokens.fields.uses_allowed" />
|
||||
<NumberField source="pending" sortable={false} label="resources.registration_tokens.fields.pending" />
|
||||
<NumberField source="completed" sortable={false} label="resources.registration_tokens.fields.completed" />
|
||||
<DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.expiry_time" />
|
||||
<DateField
|
||||
source="expiry_time"
|
||||
showTime
|
||||
options={DATE_FORMAT}
|
||||
sortable={false}
|
||||
label="resources.registration_tokens.fields.expiry_time"
|
||||
/>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
@@ -22,8 +22,8 @@ import {
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import { ReportMediaContent } from "../components/media";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
|
||||
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
@@ -90,10 +90,16 @@ const ReportShowActions = () => {
|
||||
};
|
||||
|
||||
export const ReportList = (props: ListProps) => (
|
||||
<List {...props} pagination={<ReportPagination />} sort={{ field: "received_ts", order: "DESC" }}>
|
||||
<List {...props} pagination={<ReportPagination />} perPage={50} sort={{ field: "received_ts", order: "DESC" }}>
|
||||
<DatagridConfigurable rowClick="show" bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} label="resources.reports.fields.id" />
|
||||
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} label="resources.reports.fields.received_ts" />
|
||||
<DateField
|
||||
source="received_ts"
|
||||
showTime
|
||||
options={DATE_FORMAT}
|
||||
sortable={true}
|
||||
label="resources.reports.fields.received_ts"
|
||||
/>
|
||||
<TextField sortable={false} source="user_id" label="resources.reports.fields.user_id" />
|
||||
<TextField sortable={false} source="name" label="resources.reports.fields.name" />
|
||||
<TextField sortable={false} source="score" label="resources.reports.fields.score" />
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
BooleanField,
|
||||
BulkDeleteButton,
|
||||
@@ -25,9 +26,9 @@ import {
|
||||
useRefresh,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
|
||||
import { MakeAdminBtn } from "./rooms";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
||||
|
||||
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
||||
@@ -135,17 +136,13 @@ const RoomDirectoryListActions = () => (
|
||||
);
|
||||
|
||||
export const RoomDirectoryList = () => (
|
||||
<List pagination={<RoomDirectoryPagination />} perPage={100} actions={<RoomDirectoryListActions />}>
|
||||
<List pagination={<RoomDirectoryPagination />} perPage={50} actions={<RoomDirectoryListActions />}>
|
||||
<DatagridConfigurable
|
||||
rowClick={id => "/rooms/" + id + "/show"}
|
||||
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
|
||||
omit={["room_id", "canonical_alias", "topic"]}
|
||||
>
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sx={{ height: "40px", width: "40px" }}
|
||||
label="resources.rooms.fields.avatar"
|
||||
/>
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} label="resources.rooms.fields.avatar" />
|
||||
<TextField source="name" sortable={false} label="resources.rooms.fields.name" />
|
||||
<TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" />
|
||||
<TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" />
|
||||
|
@@ -4,15 +4,22 @@ import UserIcon from "@mui/icons-material/Group";
|
||||
import HttpsIcon from "@mui/icons-material/Https";
|
||||
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
|
||||
import PageviewIcon from "@mui/icons-material/Pageview";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import RoomIcon from "@mui/icons-material/ViewList";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BooleanField,
|
||||
DateField,
|
||||
EditButton,
|
||||
WrapperField,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
@@ -38,26 +45,23 @@ import {
|
||||
useTranslate,
|
||||
useListContext,
|
||||
useNotify,
|
||||
DeleteButton,
|
||||
} from "react-admin";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { Confirm } from "react-admin";
|
||||
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
RoomDirectoryBulkUnpublishButton,
|
||||
RoomDirectoryBulkPublishButton,
|
||||
RoomDirectoryUnpublishButton,
|
||||
RoomDirectoryPublishButton,
|
||||
} from "./room_directory";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||
import { MediaIDField } from "../components/media";
|
||||
import { Room } from "../synapse/dataProvider";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { Confirm } from "react-admin";
|
||||
import { useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
|
||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
const RoomTitle = () => {
|
||||
@@ -106,7 +110,6 @@ export const MakeAdminBtn = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const ownMXID = localStorage.getItem("user_id") || "";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [userIdValue, setUserIdValue] = useState(ownMXID);
|
||||
@@ -130,12 +133,12 @@ export const MakeAdminBtn = () => {
|
||||
setOpen(false);
|
||||
setUserIdValue("");
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: err => {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
notify("resources.rooms.action.make_admin.failure", { type: "error", messageArgs: { errMsg: errorMessage } });
|
||||
setOpen(false);
|
||||
setUserIdValue("");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -157,8 +160,16 @@ export const MakeAdminBtn = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (<>
|
||||
<Button size="small" onClick={(e) => { e.stopPropagation(); setOpen(true); }} disabled={isPending}>
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
<PersonIcon /> {translate("resources.rooms.action.make_admin.assign_admin")}
|
||||
</Button>
|
||||
<Confirm
|
||||
@@ -167,20 +178,27 @@ export const MakeAdminBtn = () => {
|
||||
onClose={handleDialogClose}
|
||||
confirm="resources.rooms.action.make_admin.confirm"
|
||||
cancel="ra.action.cancel"
|
||||
title={translate("resources.rooms.action.make_admin.title", { roomName: record.name ? record.name : record.room_id })}
|
||||
content={<>
|
||||
<Typography sx={{ marginBottom: 2, whiteSpace: "pre-line"}}>{translate("resources.rooms.action.make_admin.content")}</Typography>
|
||||
<TextField
|
||||
type="text"
|
||||
variant="filled"
|
||||
value={userIdValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
label={"Matrix ID"}
|
||||
/>
|
||||
</>}
|
||||
title={translate("resources.rooms.action.make_admin.title", {
|
||||
roomName: record.name ? record.name : record.room_id,
|
||||
})}
|
||||
content={
|
||||
<>
|
||||
<Typography sx={{ marginBottom: 2, whiteSpace: "pre-line" }}>
|
||||
{translate("resources.rooms.action.make_admin.content")}
|
||||
</Typography>
|
||||
<TextField
|
||||
type="text"
|
||||
variant="filled"
|
||||
value={userIdValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
label={"Matrix ID"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomShow = (props: ShowProps) => {
|
||||
@@ -190,11 +208,7 @@ export const RoomShow = (props: ShowProps) => {
|
||||
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
||||
<TabbedShowLayout>
|
||||
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
||||
<AvatarField
|
||||
source="avatar"
|
||||
sx={{ height: "120px", width: "120px" }}
|
||||
label="resources.rooms.fields.avatar"
|
||||
/>
|
||||
<AvatarField source="avatar" sx={{ height: "120px", width: "120px" }} label="resources.rooms.fields.avatar" />
|
||||
<RaTextField source="room_id" />
|
||||
<RaTextField source="name" />
|
||||
<RaTextField source="topic" />
|
||||
@@ -231,6 +245,16 @@ export const RoomShow = (props: ShowProps) => {
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab label="synapseadmin.rooms.tabs.media" icon={<PermMediaIcon />} path="media">
|
||||
<Alert severity="warning">{translate("resources.room_media.helper.info")}</Alert>
|
||||
<ReferenceManyField reference="room_media" target="room_id" label={false}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<MediaIDField source="media_id" />
|
||||
<DeleteButton mutationMode="pessimistic" redirect={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab label="synapseadmin.rooms.tabs.permission" icon={<VisibilityIcon />} path="permission">
|
||||
<BooleanField source="federatable" />
|
||||
<BooleanField source="public" />
|
||||
@@ -287,7 +311,11 @@ export const RoomShow = (props: ShowProps) => {
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<RaTextField source="type" sortable={false} />
|
||||
<DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} />
|
||||
<FunctionField source="content" sortable={false} render={record => `${JSON.stringify(record.content, null, 2)}`} />
|
||||
<FunctionField
|
||||
source="content"
|
||||
sortable={false}
|
||||
render={record => `${JSON.stringify(record.content, null, 2)}`}
|
||||
/>
|
||||
<ReferenceField source="sender" reference="users" sortable={false}>
|
||||
<RaTextField source="id" />
|
||||
</ReferenceField>
|
||||
@@ -352,15 +380,23 @@ export const RoomList = (props: ListProps) => {
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
filters={roomFilters}
|
||||
actions={<RoomListActions />}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable
|
||||
rowClick="show"
|
||||
bulkActionButtons={<RoomBulkActionButtons />}
|
||||
omit={["joined_local_members", "state_events", "version", "federatable"]}
|
||||
>
|
||||
<ReferenceField reference="rooms" source="id" label="resources.users.fields.avatar" link={false} sortable={false}>
|
||||
<ReferenceField
|
||||
reference="rooms"
|
||||
source="id"
|
||||
label="resources.users.fields.avatar"
|
||||
link={false}
|
||||
sortable={false}
|
||||
>
|
||||
<AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} />
|
||||
</ReferenceField>
|
||||
<RaTextField source="id" label="resources.rooms.fields.room_id" sortable={false} />
|
||||
<WrapperField source="encryption" sortBy="is_encrypted" label="resources.rooms.fields.encryption">
|
||||
<BooleanField
|
||||
source="is_encrypted"
|
||||
@@ -374,7 +410,15 @@ export const RoomList = (props: ListProps) => {
|
||||
}}
|
||||
/>
|
||||
</WrapperField>
|
||||
<FunctionField source="name" render={record => record["name"] || record["canonical_alias"] || record["id"]} label="resources.rooms.fields.name" />
|
||||
<FunctionField
|
||||
source="name"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
render={record => record["name"] || record["canonical_alias"] || record["id"]}
|
||||
label="resources.rooms.fields.name"
|
||||
/>
|
||||
<RaTextField source="joined_members" label="resources.rooms.fields.joined_members" />
|
||||
<RaTextField source="joined_local_members" label="resources.rooms.fields.joined_local_members" />
|
||||
<RaTextField source="state_events" label="resources.rooms.fields.state_events" />
|
||||
|
@@ -14,13 +14,14 @@ import {
|
||||
useListContext,
|
||||
} from "react-admin";
|
||||
|
||||
import { DeleteMediaButton } from "../components/media";
|
||||
import { DeleteMediaButton, PurgeRemoteMediaButton } from "../components/media";
|
||||
|
||||
const ListActions = () => {
|
||||
const { isLoading, total } = useListContext();
|
||||
return (
|
||||
<TopToolbar>
|
||||
<DeleteMediaButton />
|
||||
<PurgeRemoteMediaButton />
|
||||
<ExportButton disabled={isLoading || total === 0} />
|
||||
</TopToolbar>
|
||||
);
|
||||
@@ -37,6 +38,7 @@ export const UserMediaStatsList = (props: ListProps) => (
|
||||
filters={userMediaStatsFilters}
|
||||
pagination={<UserMediaStatsPagination />}
|
||||
sort={{ field: "media_length", order: "DESC" }}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
|
||||
<TextField source="user_id" label="resources.users.fields.id" />
|
||||
|
@@ -1,18 +1,19 @@
|
||||
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
|
||||
import ContactMailIcon from "@mui/icons-material/ContactMail";
|
||||
import DevicesIcon from "@mui/icons-material/Devices";
|
||||
import DocumentScannerIcon from "@mui/icons-material/DocumentScanner";
|
||||
import GetAppIcon from "@mui/icons-material/GetApp";
|
||||
import UserIcon from "@mui/icons-material/Group";
|
||||
import LockClockIcon from "@mui/icons-material/LockClock";
|
||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
import LockClockIcon from '@mui/icons-material/LockClock';
|
||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Typography } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
SaveButton,
|
||||
CreateButton,
|
||||
ExportButton,
|
||||
BulkDeleteButton,
|
||||
TopToolbar,
|
||||
Toolbar,
|
||||
NumberField,
|
||||
@@ -65,21 +67,23 @@ import {
|
||||
useCreate,
|
||||
useRedirect,
|
||||
} from "react-admin";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { MakeAdminBtn } from "./rooms";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
import DeleteUserButton from "../components/DeleteUserButton";
|
||||
import { isASManaged } from "../utils/mxid";
|
||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import DeviceRemoveButton from "../components/DeviceRemoveButton";
|
||||
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
||||
import { generateRandomPassword } from "../utils/password";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import ExperimentalFeaturesList from "../components/ExperimentalFeatures";
|
||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
||||
import UserAccountData from "../components/UserAccountData";
|
||||
import UserRateLimits from "../components/UserRateLimits";
|
||||
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
||||
import { User, UsernameAvailabilityResult } from "../synapse/dataProvider";
|
||||
import { MakeAdminBtn } from "./rooms";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import decodeURLComponent from "../utils/decodeURLComponent";
|
||||
import { isASManaged } from "../utils/mxid";
|
||||
import { generateRandomPassword } from "../utils/password";
|
||||
|
||||
const choices_medium = [
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
@@ -111,9 +115,14 @@ const userFilters = [
|
||||
<BooleanInput source="guests" alwaysOn />,
|
||||
<BooleanInput label="resources.users.fields.show_deactivated" source="deactivated" alwaysOn />,
|
||||
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
||||
<BooleanInput label="resources.users.fields.show_suspended" source="suspended" alwaysOn />,
|
||||
];
|
||||
|
||||
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean; asManagedUserIsSelected: boolean }> = props => {
|
||||
const UserPreventSelfDelete: React.FC<{
|
||||
children: React.ReactNode;
|
||||
ownUserIsSelected: boolean;
|
||||
asManagedUserIsSelected: boolean;
|
||||
}> = props => {
|
||||
const ownUserIsSelected = props.ownUserIsSelected;
|
||||
const asManagedUserIsSelected = props.asManagedUserIsSelected;
|
||||
const notify = useNotify();
|
||||
@@ -162,22 +171,44 @@ export const UserList = (props: ListProps) => (
|
||||
<List
|
||||
{...props}
|
||||
filters={userFilters}
|
||||
filterDefaultValues={{ guests: true, deactivated: false, locked: false }}
|
||||
filterDefaultValues={{ deactivated: false, locked: false, suspended: false }}
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
actions={<UserListActions />}
|
||||
pagination={<UserPagination />}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable
|
||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
|
||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${encodeURIComponent(id)}`}
|
||||
bulkActionButtons={<UserBulkActionButtons />}
|
||||
>
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" label="resources.users.fields.avatar" />
|
||||
<TextField source="id" sortBy="name" label="resources.users.fields.id" />
|
||||
<TextField source="displayname" label="resources.users.fields.displayname" />
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sx={{ height: "40px", width: "40px" }}
|
||||
sortBy="avatar_url"
|
||||
label="resources.users.fields.avatar"
|
||||
/>
|
||||
<TextField
|
||||
source="id"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
sortBy="name"
|
||||
label="resources.users.fields.id"
|
||||
/>
|
||||
<TextField
|
||||
source="displayname"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
label="resources.users.fields.displayname"
|
||||
/>
|
||||
<BooleanField source="is_guest" label="resources.users.fields.is_guest" />
|
||||
<BooleanField source="admin" label="resources.users.fields.admin" />
|
||||
<BooleanField source="deactivated" label="resources.users.fields.deactivated" />
|
||||
<BooleanField source="locked" label="resources.users.fields.locked" />
|
||||
<BooleanField source="suspended" label="resources.users.fields.suspended" />
|
||||
<BooleanField source="erased" sortable={false} label="resources.users.fields.erased" />
|
||||
<DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
</DatagridConfigurable>
|
||||
@@ -188,7 +219,7 @@ export const UserList = (props: ListProps) => (
|
||||
// here only local part of user_id
|
||||
// maxLength = 255 - "@" - ":" - storage.getItem("home_server").length
|
||||
// storage.getItem("home_server").length is not valid here
|
||||
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id")];
|
||||
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-\+/]+$/, "synapseadmin.users.invalid_user_id")];
|
||||
|
||||
const validateAddress = [required(), maxLength(255)];
|
||||
|
||||
@@ -207,13 +238,13 @@ const UserEditActions = () => {
|
||||
<TopToolbar>
|
||||
{!record?.deactivated && <ServerNoticeButton />}
|
||||
{record && record.id && (
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<DeleteUserButton
|
||||
selectedIds={[record?.id]}
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
confirmContent="resources.users.helper.erase_text"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<DeleteUserButton
|
||||
selectedIds={[record?.id]}
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
confirmContent="resources.users.helper.erase_text"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
)}
|
||||
</TopToolbar>
|
||||
);
|
||||
@@ -228,18 +259,28 @@ export const UserCreate = (props: CreateProps) => {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [userIsAvailable, setUserIsAvailable] = useState<boolean | undefined>();
|
||||
const [userAvailabilityEl, setUserAvailabilityEl] = useState<React.ReactElement | false>(<Typography component="span"></Typography>);
|
||||
const [userAvailabilityEl, setUserAvailabilityEl] = useState<React.ReactElement | false>(
|
||||
<Typography component="span"></Typography>
|
||||
);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [create] = useCreate();
|
||||
|
||||
const checkAvailability = async(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const checkAvailability = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const username = event.target.value;
|
||||
const result: UsernameAvailabilityResult = await dataProvider.checkUsernameAvailability(username);
|
||||
setUserIsAvailable(!!result?.available);
|
||||
if (result?.available) {
|
||||
setUserAvailabilityEl(<Typography component="span" variant="body2" sx={{ color: theme.palette.success.main }}>✔️ {translate("resources.users.helper.username_available")}</Typography>);
|
||||
setUserAvailabilityEl(
|
||||
<Typography component="span" variant="body2" sx={{ color: theme.palette.success.main }}>
|
||||
✔️ {translate("resources.users.helper.username_available")}
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
setUserAvailabilityEl(<Typography component="span" variant="body2" sx={{ color: theme.palette.warning.main }}>⚠️ {result?.error || "unknown error"}</Typography>);
|
||||
setUserAvailabilityEl(
|
||||
<Typography component="span" variant="body2" sx={{ color: theme.palette.warning.main }}>
|
||||
⚠️ {result?.error || "unknown error"}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,12 +291,18 @@ export const UserCreate = (props: CreateProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
create("users", { data: data }, {
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.created", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => { return `users/${resource.id}` });
|
||||
create(
|
||||
"users",
|
||||
{ data: data },
|
||||
{
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.created", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => {
|
||||
return `users/${encodeURIComponent(resource.id as string)}`;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -268,39 +315,48 @@ export const UserCreate = (props: CreateProps) => {
|
||||
};
|
||||
|
||||
const updateUser = () => {
|
||||
create("users", { data: formData }, {
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.updated", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => { return `users/${resource.id}` });
|
||||
create(
|
||||
"users",
|
||||
{ data: formData },
|
||||
{
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.updated", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => {
|
||||
return `users/${encodeURIComponent(resource.id as string)}`;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return <Create
|
||||
{...props}
|
||||
>
|
||||
<SimpleForm
|
||||
onSubmit={postSave}
|
||||
>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} onBlur={checkAvailability} helperText={userAvailabilityEl}/>
|
||||
<TextInput source="displayname" validate={maxLength(256)} />
|
||||
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<ArrayInput source="threepids">
|
||||
<SimpleFormIterator disableReordering>
|
||||
<SelectInput source="medium" choices={choices_medium} validate={required()} />
|
||||
<TextInput source="address" validate={validateAddress} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
|
||||
<SimpleFormIterator disableReordering>
|
||||
<TextInput source="auth_provider" validate={required()} />
|
||||
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
<Confirm
|
||||
return (
|
||||
<Create {...props}>
|
||||
<SimpleForm onSubmit={postSave}>
|
||||
<TextInput
|
||||
source="id"
|
||||
autoComplete="off"
|
||||
validate={validateUser}
|
||||
onBlur={checkAvailability}
|
||||
helperText={userAvailabilityEl}
|
||||
/>
|
||||
<TextInput source="displayname" validate={maxLength(256)} />
|
||||
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<ArrayInput source="threepids">
|
||||
<SimpleFormIterator disableReordering>
|
||||
<SelectInput source="medium" choices={choices_medium} validate={required()} />
|
||||
<TextInput source="address" validate={validateAddress} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
|
||||
<SimpleFormIterator disableReordering>
|
||||
<TextInput source="auth_provider" validate={required()} />
|
||||
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="resources.users.action.overwrite_title"
|
||||
content="resources.users.action.overwrite_content"
|
||||
@@ -309,7 +365,8 @@ export const UserCreate = (props: CreateProps) => {
|
||||
confirm="resources.users.action.overwrite_confirm"
|
||||
cancel="resources.users.action.overwrite_cancel"
|
||||
/>
|
||||
</Create>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTitle = () => {
|
||||
@@ -319,7 +376,7 @@ const UserTitle = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||
const username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : "";
|
||||
return (
|
||||
<span>
|
||||
{translate("resources.users.name", {
|
||||
@@ -345,7 +402,10 @@ const UserEditToolbar = () => {
|
||||
<div className={ToolbarClasses.defaultToolbar}>
|
||||
<Toolbar sx={{ justifyContent: "space-between" }}>
|
||||
<SaveButton />
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<UserPreventSelfDelete
|
||||
ownUserIsSelected={ownUserIsSelected}
|
||||
asManagedUserIsSelected={asManagedUserIsSelected}
|
||||
>
|
||||
<DeleteButton />
|
||||
</UserPreventSelfDelete>
|
||||
</Toolbar>
|
||||
@@ -388,8 +448,18 @@ const UserPasswordInput = props => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PasswordInput {...props} helperText={asManagedUserIsSelected ? "resources.users.helper.modify_managed_user_error" : (record ? "resources.users.helper.password" : "resources.users.helper.create_password")} disabled={asManagedUserIsSelected} />
|
||||
<Button
|
||||
<PasswordInput
|
||||
{...props}
|
||||
helperText={
|
||||
asManagedUserIsSelected
|
||||
? "resources.users.helper.modify_managed_user_error"
|
||||
: record
|
||||
? "resources.users.helper.password"
|
||||
: "resources.users.helper.create_password"
|
||||
}
|
||||
disabled={asManagedUserIsSelected}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
label="resources.users.action.generate_password"
|
||||
onClick={generatePassword}
|
||||
@@ -404,11 +474,17 @@ export const UserEdit = (props: EditProps) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{
|
||||
meta: {
|
||||
include: ["features"] // Tell your dataProvider to include features
|
||||
}
|
||||
}}>
|
||||
<Edit
|
||||
{...props}
|
||||
title={<UserTitle />}
|
||||
actions={<UserEditActions />}
|
||||
mutationMode="pessimistic"
|
||||
queryOptions={{
|
||||
meta: {
|
||||
include: ["features"], // Tell your dataProvider to include features
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
||||
@@ -418,20 +494,31 @@ export const UserEdit = (props: EditProps) => {
|
||||
label="resources.users.fields.avatar"
|
||||
accept={{ "image/*": [".png", ".jpg"] }}
|
||||
>
|
||||
<ImageField source="src" title="Avatar" sx={{ '& img': {
|
||||
width: "120px !important",
|
||||
height: "120px !important",
|
||||
objectFit: "cover !important",
|
||||
borderRadius: '50% !important',
|
||||
}}} />
|
||||
<ImageField
|
||||
source="src"
|
||||
title="Avatar"
|
||||
sx={{
|
||||
"& img": {
|
||||
width: "120px !important",
|
||||
height: "120px !important",
|
||||
objectFit: "cover !important",
|
||||
borderRadius: "50% !important",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ImageInput>
|
||||
<TextInput source="id" readOnly />
|
||||
<TextInput source="displayname" />
|
||||
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<UserPasswordInput
|
||||
source="password"
|
||||
autoComplete="new-password"
|
||||
helperText="resources.users.helper.password"
|
||||
/>
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<UserBooleanInput source="locked" />
|
||||
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||
<UserBooleanInput source="suspended" helperText="resources.users.helper.suspend" />
|
||||
<BooleanInput source="erased" disabled />
|
||||
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
<TextField source="consent_version" />
|
||||
@@ -489,16 +576,19 @@ export const UserEdit = (props: EditProps) => {
|
||||
target="user_id"
|
||||
label={false}
|
||||
pagination={<UserPagination />}
|
||||
perPage={50}
|
||||
perPage={10}
|
||||
sort={{ field: "created_ts", order: "DESC" }}
|
||||
>
|
||||
<Datagrid sx={{ width: "100%" }}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={<BulkDeleteButton />}>
|
||||
<MediaIDField source="media_id" />
|
||||
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
||||
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
||||
<NumberField source="media_length" />
|
||||
<TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
|
||||
<FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
|
||||
<FunctionField
|
||||
source="upload_name"
|
||||
render={record => (record.upload_name ? decodeURLComponent(record.upload_name) : "")}
|
||||
/>
|
||||
<TextField source="quarantined_by" />
|
||||
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
||||
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
||||
@@ -508,20 +598,46 @@ export const UserEdit = (props: EditProps) => {
|
||||
</FormTab>
|
||||
|
||||
<FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms">
|
||||
<ReferenceManyField reference="joined_rooms" target="user_id" label={false}>
|
||||
<Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
|
||||
<ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}>
|
||||
<AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} />
|
||||
</ReferenceField>
|
||||
<TextField source="id" label="resources.rooms.fields.room_id" sortable={false}/>
|
||||
<ReferenceField reference="rooms" source="id" label="resources.rooms.fields.name" link={false} sortable={false}>
|
||||
<TextField source="name" />
|
||||
</ReferenceField>
|
||||
<ReferenceField reference="rooms" source="id" label="resources.rooms.fields.joined_members" link={false} sortable={false}>
|
||||
<TextField source="joined_members" sortable={false} />
|
||||
</ReferenceField>
|
||||
<MakeAdminBtn />
|
||||
</Datagrid>
|
||||
<ReferenceManyField
|
||||
reference="joined_rooms"
|
||||
target="user_id"
|
||||
label={false}
|
||||
perPage={10}
|
||||
pagination={<Pagination />}
|
||||
>
|
||||
<Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
|
||||
<ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}>
|
||||
<AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} />
|
||||
</ReferenceField>
|
||||
<TextField source="id" label="resources.rooms.fields.room_id" sortable={false} />
|
||||
<ReferenceField
|
||||
reference="rooms"
|
||||
source="id"
|
||||
label="resources.rooms.fields.name"
|
||||
link={false}
|
||||
sortable={false}
|
||||
>
|
||||
<TextField
|
||||
source="name"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
/>
|
||||
</ReferenceField>
|
||||
<ReferenceField
|
||||
reference="rooms"
|
||||
source="id"
|
||||
label="resources.rooms.fields.joined_members"
|
||||
link={false}
|
||||
sortable={false}
|
||||
>
|
||||
<TextField source="joined_members" sortable={false} />
|
||||
</ReferenceField>
|
||||
<ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}>
|
||||
<MakeAdminBtn />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
@@ -551,6 +667,10 @@ export const UserEdit = (props: EditProps) => {
|
||||
<FormTab label="synapseadmin.users.tabs.limits" icon={<LockClockIcon />} path="limits">
|
||||
<UserRateLimits />
|
||||
</FormTab>
|
||||
|
||||
<FormTab label="synapseadmin.users.tabs.account_data" icon={<DocumentScannerIcon />} path="accountdata">
|
||||
<UserAccountData />
|
||||
</FormTab>
|
||||
</TabbedForm>
|
||||
</Edit>
|
||||
);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import { HttpError } from "ra-core";
|
||||
|
||||
import authProvider from "./authProvider";
|
||||
import { HttpError } from "ra-core";
|
||||
|
||||
fetchMock.enableMocks();
|
||||
|
||||
@@ -28,13 +28,14 @@ describe("authProvider", () => {
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(ret).toEqual({ redirectTo: "/" });
|
||||
expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
method: "POST",
|
||||
});
|
||||
expect(localStorage.getItem("base_url")).toEqual("http://example.com");
|
||||
@@ -59,13 +60,14 @@ describe("authProvider", () => {
|
||||
loginToken: "login_token",
|
||||
});
|
||||
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(ret).toEqual({ redirectTo: "/" });
|
||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
method: "POST",
|
||||
});
|
||||
expect(localStorage.getItem("base_url")).toEqual("https://example.com");
|
||||
@@ -85,9 +87,11 @@ describe("authProvider", () => {
|
||||
expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer foo",
|
||||
}),
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
user: { authenticated: true, token: "Bearer foo" },
|
||||
});
|
||||
expect(localStorage.getItem("access_token")).toBeNull();
|
||||
@@ -100,11 +104,15 @@ describe("authProvider", () => {
|
||||
});
|
||||
|
||||
it("should reject if error.status is 401", async () => {
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
await expect(
|
||||
authProvider.checkError(new HttpError("test-error", 401, { errcode: "test-errcode", error: "test-error" }))
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject if error.status is 403", async () => {
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 403, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
await expect(
|
||||
authProvider.checkError(new HttpError("test-error", 403, { errcode: "test-errcode", error: "test-error" }))
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin";
|
||||
|
||||
import { FetchConfig, ClearConfig, GetConfig } from "../utils/config";
|
||||
import decodeURLComponent from "../utils/decodeURLComponent";
|
||||
import { MatrixError, displayError } from "../utils/error";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
import { FetchConfig, ClearConfig } from "../utils/config";
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
// called when the user attempts to log in
|
||||
@@ -22,6 +23,11 @@ const authProvider: AuthProvider = {
|
||||
console.log("login ");
|
||||
let options: Options = {
|
||||
method: "POST",
|
||||
credentials: GetConfig().corsCredentials as RequestCredentials,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
Object.assign(
|
||||
{
|
||||
@@ -51,14 +57,15 @@ const authProvider: AuthProvider = {
|
||||
if (!base_url) {
|
||||
// there is some kind of bug with base_url being present in the form, but not submitted
|
||||
// ref: https://github.com/etkecc/synapse-admin/issues/14
|
||||
localStorage.removeItem("base_url")
|
||||
localStorage.removeItem("base_url");
|
||||
throw new Error("Homeserver URL is required.");
|
||||
}
|
||||
base_url = base_url.replace(/\/+$/g, "");
|
||||
localStorage.setItem("base_url", base_url);
|
||||
|
||||
const decoded_base_url = window.decodeURIComponent(base_url);
|
||||
let login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login");
|
||||
const decoded_base_url = decodeURLComponent(base_url);
|
||||
const login_api_url =
|
||||
decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login");
|
||||
|
||||
let response;
|
||||
|
||||
@@ -67,7 +74,7 @@ const authProvider: AuthProvider = {
|
||||
// this a login with an already obtained access token, let's just validate it
|
||||
options = {
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}),
|
||||
};
|
||||
@@ -81,24 +88,24 @@ const authProvider: AuthProvider = {
|
||||
localStorage.setItem("device_id", json.device_id);
|
||||
localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials");
|
||||
|
||||
// when doing access token auth, config is not fetched, so we need to do it here
|
||||
if (accessToken) {
|
||||
await FetchConfig();
|
||||
await FetchConfig();
|
||||
const config = GetConfig();
|
||||
let pageToRedirectTo = "/";
|
||||
|
||||
if (config && config.etkeccAdmin) {
|
||||
pageToRedirectTo = "/server_status";
|
||||
}
|
||||
|
||||
return Promise.resolve({redirectTo: "/"});
|
||||
} catch(err) {
|
||||
return Promise.resolve({ redirectTo: pageToRedirectTo });
|
||||
} catch (err) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message);
|
||||
const errMsg = errorBody?.errcode
|
||||
? displayError(errorBody.errcode, errorStatus, errorBody.error)
|
||||
: displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
return Promise.reject(
|
||||
new HttpError(
|
||||
errMsg,
|
||||
errorStatus,
|
||||
)
|
||||
);
|
||||
return Promise.reject(new HttpError(errMsg, errorStatus));
|
||||
}
|
||||
},
|
||||
getIdentity: async () => {
|
||||
@@ -147,6 +154,11 @@ const authProvider: AuthProvider = {
|
||||
|
||||
const options: Options = {
|
||||
method: "POST",
|
||||
credentials: GetConfig().corsCredentials as RequestCredentials,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
user: {
|
||||
authenticated: true,
|
||||
token: `Bearer ${access_token}`,
|
||||
@@ -169,7 +181,7 @@ const authProvider: AuthProvider = {
|
||||
const status = err.status;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return Promise.reject({message: displayError(errorBody.errcode, status, errorBody.error)});
|
||||
return Promise.reject({ message: displayError(errorBody.errcode, status, errorBody.error) });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
@@ -13,13 +13,17 @@ import {
|
||||
withLifecycleCallbacks,
|
||||
} from "react-admin";
|
||||
|
||||
import { returnMXID } from "../utils/mxid";
|
||||
import { GetConfig } from "../utils/config";
|
||||
import { MatrixError, displayError } from "../utils/error";
|
||||
import { returnMXID } from "../utils/mxid";
|
||||
|
||||
const CACHED_MANY_REF: Record<string, any> = {};
|
||||
|
||||
// Adds the access token to all requests
|
||||
const jsonClient = async (url: string, options: Options = {}) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
console.log("httpClient " + url);
|
||||
options.credentials = GetConfig().corsCredentials as RequestCredentials;
|
||||
if (token !== null) {
|
||||
options.user = {
|
||||
authenticated: true,
|
||||
@@ -33,7 +37,7 @@ const jsonClient = async (url: string, options: Options = {}) => {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode
|
||||
const errMsg = errorBody?.errcode
|
||||
? displayError(errorBody.errcode, errorStatus, errorBody.error)
|
||||
: displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
@@ -138,6 +142,7 @@ export interface User {
|
||||
external_ids: ExternalId[];
|
||||
user_type?: string;
|
||||
locked: boolean;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
@@ -249,9 +254,7 @@ export interface UploadMediaResult {
|
||||
}
|
||||
|
||||
export interface ExperimentalFeaturesModel {
|
||||
features: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
features: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RateLimitsModel {
|
||||
@@ -259,20 +262,105 @@ export interface RateLimitsModel {
|
||||
burst_count?: number;
|
||||
}
|
||||
|
||||
export interface AccountDataModel {
|
||||
account_data: {
|
||||
global: Record<string, object>;
|
||||
rooms: Record<string, object>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsernameAvailabilityResult {
|
||||
available?: boolean;
|
||||
error?: string;
|
||||
errcode?: string;
|
||||
}
|
||||
|
||||
export interface ServerStatusComponent {
|
||||
ok: boolean;
|
||||
category: string;
|
||||
reason: string;
|
||||
url: string;
|
||||
help: string;
|
||||
label: {
|
||||
url: string;
|
||||
icon: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerStatusResponse {
|
||||
success: boolean;
|
||||
ok: boolean;
|
||||
host: string;
|
||||
results: ServerStatusComponent[];
|
||||
}
|
||||
|
||||
export interface ServerProcessResponse {
|
||||
locked_at: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface ServerNotification {
|
||||
event_id: string;
|
||||
output: string;
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
export interface ServerNotificationsResponse {
|
||||
success: boolean;
|
||||
notifications: ServerNotification[];
|
||||
}
|
||||
|
||||
export interface ServerCommand {
|
||||
icon: string;
|
||||
name: string;
|
||||
description: string;
|
||||
args: boolean;
|
||||
with_lock: boolean;
|
||||
additionalArgs?: string;
|
||||
}
|
||||
|
||||
export type ServerCommandsResponse = Record<string, ServerCommand>;
|
||||
|
||||
export interface ScheduledCommand {
|
||||
args: string;
|
||||
command: string;
|
||||
id: string;
|
||||
is_recurring: boolean;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
export interface RecurringCommand {
|
||||
args: string;
|
||||
command: string;
|
||||
id: string;
|
||||
scheduled_at: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface SynapseDataProvider extends DataProvider {
|
||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
|
||||
getRateLimits: (id: Identifier) => Promise<RateLimitsModel>;
|
||||
setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>;
|
||||
getAccountData: (id: Identifier) => Promise<AccountDataModel>;
|
||||
checkUsernameAvailability: (username: string) => Promise<UsernameAvailabilityResult>;
|
||||
makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>;
|
||||
getServerRunningProcess: (etkeAdminUrl: string) => Promise<ServerProcessResponse>;
|
||||
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
|
||||
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
|
||||
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
|
||||
getServerCommands: (etkeAdminUrl: string) => Promise<ServerCommandsResponse>;
|
||||
getScheduledCommands: (etkeAdminUrl: string) => Promise<ScheduledCommand[]>;
|
||||
getRecurringCommands: (etkeAdminUrl: string) => Promise<RecurringCommand[]>;
|
||||
createScheduledCommand: (etkeAdminUrl: string, command: Partial<ScheduledCommand>) => Promise<ScheduledCommand>;
|
||||
updateScheduledCommand: (etkeAdminUrl: string, command: ScheduledCommand) => Promise<ScheduledCommand>;
|
||||
deleteScheduledCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||
createRecurringCommand: (etkeAdminUrl: string, command: Partial<RecurringCommand>) => Promise<RecurringCommand>;
|
||||
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise<RecurringCommand>;
|
||||
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
@@ -357,6 +445,20 @@ const resourceMap = {
|
||||
data: "members",
|
||||
total: json => json.total,
|
||||
},
|
||||
room_media: {
|
||||
map: (mediaId: string) => ({
|
||||
id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""),
|
||||
media_id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""),
|
||||
}),
|
||||
reference: (id: Identifier) => ({
|
||||
endpoint: `/_synapse/admin/v1/room/${id}/media`,
|
||||
}),
|
||||
total: json => json.total,
|
||||
data: "local",
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`,
|
||||
}),
|
||||
},
|
||||
room_state: {
|
||||
map: (rs: RoomState) => ({
|
||||
...rs,
|
||||
@@ -548,7 +650,7 @@ function getSearchOrder(order: "ASC" | "DESC") {
|
||||
const baseDataProvider: SynapseDataProvider = {
|
||||
getList: async (resource, params) => {
|
||||
console.log("getList " + resource);
|
||||
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
||||
const { user_id, name, guests, deactivated, locked, suspended, search_term, destination, valid } = params.filter;
|
||||
const { page, perPage } = params.pagination as PaginationPayload;
|
||||
const { field, order } = params.sort as SortPayload;
|
||||
const from = (page - 1) * perPage;
|
||||
@@ -562,6 +664,7 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
guests: guests,
|
||||
deactivated: deactivated,
|
||||
locked: locked,
|
||||
suspended: suspended,
|
||||
valid: valid,
|
||||
order_by: field,
|
||||
dir: getSearchOrder(order),
|
||||
@@ -604,21 +707,23 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const endpoint_url = base_url + res.path;
|
||||
const responses = await Promise.all(params.ids.map(id => {
|
||||
// edge case: when user is external / federated, homeserver will return error, as querying external users via
|
||||
// /_synapse/admin/v2/users is not allowed.
|
||||
// That leads to an issue when a user is referenced (e.g., in room state datagrid) - the user cell is just empty.
|
||||
// To avoid that, we fake the response with one specific field (name) which is used in the datagrid.
|
||||
if (homeserver && resource === "users") {
|
||||
if (!(<string>id).endsWith(homeserver)) {
|
||||
const json = {
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(id => {
|
||||
// edge case: when user is external / federated, homeserver will return error, as querying external users via
|
||||
// /_synapse/admin/v2/users is not allowed.
|
||||
// That leads to an issue when a user is referenced (e.g., in room state datagrid) - the user cell is just empty.
|
||||
// To avoid that, we fake the response with one specific field (name) which is used in the datagrid.
|
||||
if (homeserver && resource === "users") {
|
||||
if (!(id as string).endsWith(homeserver)) {
|
||||
const json = {
|
||||
name: id,
|
||||
};
|
||||
return Promise.resolve({ json });
|
||||
};
|
||||
return Promise.resolve({ json });
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`);
|
||||
}));
|
||||
return jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`);
|
||||
})
|
||||
);
|
||||
return {
|
||||
data: responses.map(({ json }) => res.map(json)),
|
||||
total: responses.length,
|
||||
@@ -643,12 +748,29 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const ref = res.reference(params.id);
|
||||
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||
const CACHE_KEY = ref.endpoint;
|
||||
let jsonData = [];
|
||||
let total = 0;
|
||||
|
||||
if (CACHED_MANY_REF[CACHE_KEY]) {
|
||||
jsonData = CACHED_MANY_REF[CACHE_KEY]["data"].slice(from, from + perPage);
|
||||
total = CACHED_MANY_REF[CACHE_KEY]["total"];
|
||||
} else {
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
jsonData = json[res.data];
|
||||
total = res.total(json, from, perPage);
|
||||
if (resource === "joined_rooms") {
|
||||
// cache will be applied only for joined_rooms
|
||||
CACHED_MANY_REF[CACHE_KEY] = { data: jsonData, total: total };
|
||||
jsonData = jsonData.slice(from, from + perPage);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: json[res.data].map(res.map),
|
||||
total: res.total(json, from, perPage),
|
||||
data: jsonData.map(res.map),
|
||||
total: total,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -806,6 +928,23 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
return json as DeleteMediaResult;
|
||||
},
|
||||
|
||||
/**
|
||||
* Purge remote media by date
|
||||
*
|
||||
* @link https://element-hq.github.io/synapse/latest/admin_api/media_admin_api.html#purge-remote-media-api
|
||||
*
|
||||
* @param before_ts Unix timestamp in milliseconds. Files that were last used before this timestamp will be deleted. It is the timestamp of last access, not the timestamp when the file was created.
|
||||
* @returns
|
||||
*/
|
||||
purgeRemoteMedia: async ({ before_ts }) => {
|
||||
const endpoint = `/_synapse/admin/v1/purge_media_cache?before_ts=${before_ts}`;
|
||||
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = base_url + endpoint;
|
||||
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
||||
return json as DeleteMediaResult;
|
||||
},
|
||||
|
||||
uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
|
||||
@@ -837,10 +976,16 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json as RateLimitsModel;
|
||||
},
|
||||
getAccountData: async (id: Identifier) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/accountdata`;
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json as AccountDataModel;
|
||||
},
|
||||
setRateLimits: async (id: Identifier, rateLimits: RateLimitsModel) => {
|
||||
const filtered = Object.entries(rateLimits).
|
||||
filter(([key, value]) => value !== null && value !== undefined).
|
||||
reduce((obj, [key, value]) => {
|
||||
const filtered = Object.entries(rateLimits)
|
||||
.filter(([key, value]) => value !== null && value !== undefined)
|
||||
.reduce((obj, [key, value]) => {
|
||||
obj[key] = value;
|
||||
return obj;
|
||||
}, {});
|
||||
@@ -849,7 +994,7 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`;
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(filtered) });
|
||||
@@ -880,7 +1025,399 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
getServerRunningProcess: async (etkeAdminUrl: string, burstCache = false): Promise<ServerProcessResponse> => {
|
||||
const locked_at = "";
|
||||
const command = "";
|
||||
|
||||
let serverURL = `${etkeAdminUrl}/lock`;
|
||||
if (burstCache) {
|
||||
serverURL += `?time=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(serverURL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server running process: ${response.status} ${response.statusText}`);
|
||||
return { locked_at, command };
|
||||
}
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as { locked_at: string; command: string };
|
||||
}
|
||||
if (status === 204) {
|
||||
return { locked_at, command };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting server running process", error);
|
||||
}
|
||||
|
||||
return { locked_at, command };
|
||||
},
|
||||
getServerStatus: async (etkeAdminUrl: string, burstCache = false): Promise<ServerStatusResponse> => {
|
||||
let serverURL = `${etkeAdminUrl}/status`;
|
||||
if (burstCache) {
|
||||
serverURL += `?time=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(serverURL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server status: ${response.status} ${response.statusText}`);
|
||||
return { success: false, ok: false, host: "", results: [] };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
const result = { success: true, ...json } as ServerStatusResponse;
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting server status", error);
|
||||
}
|
||||
|
||||
return { success: false, ok: false, host: "", results: [] };
|
||||
},
|
||||
getServerNotifications: async (
|
||||
serverNotificationsUrl: string,
|
||||
burstCache = false
|
||||
): Promise<ServerNotificationsResponse> => {
|
||||
let serverURL = `${serverNotificationsUrl}/notifications`;
|
||||
if (burstCache) {
|
||||
serverURL += `?time=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(serverURL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server notifications: ${response.status} ${response.statusText}`);
|
||||
return { success: false, notifications: [] };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 204) {
|
||||
return { success: true, notifications: [] };
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
const result = { success: true, notifications: json } as ServerNotificationsResponse;
|
||||
return result;
|
||||
}
|
||||
|
||||
return { success: true, notifications: [] };
|
||||
} catch (error) {
|
||||
console.error("Error getting server notifications", error);
|
||||
}
|
||||
|
||||
return { success: false, notifications: [] };
|
||||
},
|
||||
deleteServerNotifications: async (serverNotificationsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverNotificationsUrl}/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting server notifications: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 204) {
|
||||
const result = { success: true };
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting server notifications", error);
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
},
|
||||
getServerCommands: async (serverCommandsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverCommandsUrl}/commands`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching server commands: ${response.status} ${response.statusText}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as ServerCommandsResponse;
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error("Error fetching server commands, error");
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
runServerCommand: async (serverCommandsUrl: string, command: string, additionalArgs: Record<string, any> = {}) => {
|
||||
const endpoint_url = `${serverCommandsUrl}/commands`;
|
||||
const body = {
|
||||
command: command,
|
||||
...additionalArgs,
|
||||
};
|
||||
const response = await fetch(endpoint_url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error running server command: ${response.status} ${response.statusText}`);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 204) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
},
|
||||
getScheduledCommands: async (scheduledCommandsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching scheduled commands: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as ScheduledCommand[];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching scheduled commands, error");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
getRecurringCommands: async (recurringCommandsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching recurring commands: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand[];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching recurring commands, error");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
createScheduledCommand: async (scheduledCommandsUrl: string, command: Partial<ScheduledCommand>) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error creating scheduled command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to create scheduled command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return command as ScheduledCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as ScheduledCommand;
|
||||
} catch (error) {
|
||||
console.error("Error creating scheduled command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateScheduledCommand: async (scheduledCommandsUrl: string, command: ScheduledCommand) => {
|
||||
try {
|
||||
// Use the base endpoint without ID and use PUT for upsert
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
method: "PUT", // Using PUT on the base endpoint
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const jsonErr = JSON.parse(await response.text());
|
||||
console.error(`Error updating scheduled command: ${response.status} ${response.statusText}`);
|
||||
throw new Error(jsonErr.error);
|
||||
}
|
||||
|
||||
// According to docs, successful response is 204 No Content
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command;
|
||||
}
|
||||
|
||||
// If server does return data (though docs suggest it returns 204)
|
||||
const json = await response.json();
|
||||
console.log("JSON", json);
|
||||
return json as ScheduledCommand;
|
||||
} catch (error) {
|
||||
console.error("Error updating scheduled command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteScheduledCommand: async (scheduledCommandsUrl: string, id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting scheduled command: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting scheduled command", error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
createRecurringCommand: async (recurringCommandsUrl: string, command: Partial<RecurringCommand>) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error creating recurring command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to create recurring command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command as RecurringCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand;
|
||||
} catch (error) {
|
||||
console.error("Error creating recurring command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateRecurringCommand: async (recurringCommandsUrl: string, command: RecurringCommand) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error updating recurring command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to update recurring command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command as RecurringCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand;
|
||||
} catch (error) {
|
||||
console.error("Error updating recurring command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteRecurringCommand: async (recurringCommandsUrl: string, id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting recurring command: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting recurring command", error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
|
@@ -1,7 +1,9 @@
|
||||
export interface Config {
|
||||
restrictBaseUrl: string | string[];
|
||||
corsCredentials: string;
|
||||
asManagedUsers: RegExp[];
|
||||
menu: MenuItem[];
|
||||
etkeccAdmin?: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -15,45 +17,67 @@ export const WellKnownKey = "cc.etke.synapse-admin";
|
||||
// current configuration
|
||||
let config: Config = {
|
||||
restrictBaseUrl: "",
|
||||
corsCredentials: "same-origin",
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
etkeccAdmin: "",
|
||||
};
|
||||
|
||||
export const FetchConfig = async () => {
|
||||
// load config.json and honor vite base url (import.meta.env.BASE_URL)
|
||||
// if that url doesn't have a trailing slash - add it
|
||||
let configJSONUrl = "config.json";
|
||||
if (import.meta.env.BASE_URL) {
|
||||
configJSONUrl = `${import.meta.env.BASE_URL.replace(/\/?$/, "/")}config.json`;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("config.json");
|
||||
const resp = await fetch(configJSONUrl);
|
||||
const configJSON = await resp.json();
|
||||
console.log("Loaded config.json", configJSON);
|
||||
console.log("Loaded", configJSONUrl, configJSON);
|
||||
LoadConfig(configJSON);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
let protocol = "https";
|
||||
const baseURL = localStorage.getItem("base_url");
|
||||
if (baseURL && baseURL.startsWith("http://")) {
|
||||
protocol = "http";
|
||||
}
|
||||
|
||||
// if home_server is set, try to load https://home_server/.well-known/matrix/client
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
if (homeserver) {
|
||||
try {
|
||||
const resp = await fetch(`https://${homeserver}/.well-known/matrix/client`);
|
||||
const configWK = await resp.json();
|
||||
const resp = await fetch(`${protocol}://${homeserver}/.well-known/matrix/client`);
|
||||
const configWK = await resp.json();
|
||||
if (!configWK[WellKnownKey]) {
|
||||
console.log(`Loaded https://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK);
|
||||
console.log(
|
||||
`Loaded ${protocol}://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`,
|
||||
configWK
|
||||
);
|
||||
} else {
|
||||
console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK);
|
||||
LoadConfig(configWK[WellKnownKey]);
|
||||
}
|
||||
console.log(`Loaded ${protocol}://${homeserver}.well-known/matrix/client`, configWK);
|
||||
LoadConfig(configWK[WellKnownKey]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`https://${homeserver}/.well-known/matrix/client not found, skipping`, e);
|
||||
console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// load config from context
|
||||
// we deliberately processing each key separately to avoid overwriting the whole config, loosing some keys, and messing
|
||||
// with typescript types
|
||||
export const LoadConfig = (context: any) => {
|
||||
if (context?.restrictBaseUrl) {
|
||||
config.restrictBaseUrl = context.restrictBaseUrl as string | string[];
|
||||
}
|
||||
|
||||
if (context?.corsCredentials) {
|
||||
config.corsCredentials = context.corsCredentials;
|
||||
}
|
||||
|
||||
if (context?.asManagedUsers) {
|
||||
config.asManagedUsers = context.asManagedUsers.map((regex: string) => new RegExp(regex));
|
||||
}
|
||||
@@ -65,13 +89,16 @@ export const LoadConfig = (context: any) => {
|
||||
if (menu.length > 0) {
|
||||
config.menu = menu;
|
||||
}
|
||||
}
|
||||
|
||||
if (context?.etkeccAdmin) {
|
||||
config.etkeccAdmin = context.etkeccAdmin;
|
||||
}
|
||||
};
|
||||
|
||||
// get config
|
||||
export const GetConfig = (): Config => {
|
||||
return config;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// clear config
|
||||
export const ClearConfig = () => {
|
||||
@@ -79,4 +106,4 @@ export const ClearConfig = () => {
|
||||
config = {} as Config;
|
||||
// session
|
||||
localStorage.clear();
|
||||
}
|
||||
};
|
||||
|
@@ -26,3 +26,29 @@ export const dateFormatter = (v: string | number | Date | undefined | null): str
|
||||
// target format yyyy-MM-ddThh:mm
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
};
|
||||
|
||||
// assuming date is in format "2025-02-26 20:52:00" where no timezone is specified
|
||||
export const getTimeSince = (dateToCompare: string) => {
|
||||
const nowUTC = new Date().getTime();
|
||||
if (!dateToCompare.includes("Z")) {
|
||||
dateToCompare = dateToCompare + "Z";
|
||||
}
|
||||
const past = new Date(dateToCompare);
|
||||
|
||||
const pastUTC = past.getTime();
|
||||
const diffInMs = nowUTC - pastUTC;
|
||||
|
||||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return "a couple of seconds";
|
||||
if (diffInMinutes === 1) return "1 minute";
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} minutes`;
|
||||
if (diffInMinutes < 120) return "1 hour";
|
||||
if (diffInMinutes < 24 * 60) return `${Math.floor(diffInMinutes / 60)} hours`;
|
||||
if (diffInMinutes < 48 * 60) return "1 day";
|
||||
if (diffInMinutes < 7 * 24 * 60) return `${Math.floor(diffInMinutes / (24 * 60))} days`;
|
||||
if (diffInMinutes < 14 * 24 * 60) return "1 week";
|
||||
if (diffInMinutes < 30 * 24 * 60) return `${Math.floor(diffInMinutes / (7 * 24 * 60))} weeks`;
|
||||
if (diffInMinutes < 60 * 24 * 60) return "1 month";
|
||||
return `${Math.floor(diffInMinutes / (30 * 24 * 60))} months`;
|
||||
};
|
||||
|
15
src/utils/decodeURLComponent.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Decode a URI component, and if it fails, return the original string.
|
||||
* @param str The string to decode.
|
||||
* @returns The decoded string, or the original string if decoding fails.
|
||||
* @example decodeURIComponent("Hello%20World") // "Hello World"
|
||||
*/
|
||||
const decodeURLComponent = (str: any): any => {
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
export default decodeURLComponent;
|
@@ -1,6 +1,6 @@
|
||||
export type MatrixError = {
|
||||
errcode: string;
|
||||
error: string;
|
||||
export interface MatrixError {
|
||||
errcode: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`;
|
@@ -1,12 +1,12 @@
|
||||
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
|
||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||
const ret = re.exec(mxcUrl);
|
||||
if (ret == null) {
|
||||
throw new Error("Invalid mxcUrl");
|
||||
}
|
||||
const serverName = ret[1];
|
||||
const mediaId = ret[2];
|
||||
return { serverName, mediaId };
|
||||
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string; mediaId: string } => {
|
||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||
const ret = re.exec(mxcUrl);
|
||||
if (ret == null) {
|
||||
throw new Error("Invalid mxcUrl");
|
||||
}
|
||||
const serverName = ret[1];
|
||||
const mediaId = ret[2];
|
||||
return { serverName, mediaId };
|
||||
};
|
||||
|
||||
export type MediaType = "thumbnail" | "original";
|
||||
|
@@ -1,8 +1,13 @@
|
||||
import AnnouncementIcon from '@mui/icons-material/Announcement';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import HelpCenterIcon from '@mui/icons-material/HelpCenter';
|
||||
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import AnnouncementIcon from "@mui/icons-material/Announcement";
|
||||
import EngineeringIcon from "@mui/icons-material/Engineering";
|
||||
import HelpCenterIcon from "@mui/icons-material/HelpCenter";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import PieChartIcon from "@mui/icons-material/PieChart";
|
||||
import PriceCheckIcon from "@mui/icons-material/PriceCheck";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import RouterIcon from "@mui/icons-material/Router";
|
||||
import SupportAgentIcon from "@mui/icons-material/SupportAgent";
|
||||
import UpgradeIcon from "@mui/icons-material/Upgrade";
|
||||
|
||||
export const Icons = {
|
||||
Announcement: AnnouncementIcon,
|
||||
@@ -10,6 +15,11 @@ export const Icons = {
|
||||
HelpCenter: HelpCenterIcon,
|
||||
SupportAgent: SupportAgentIcon,
|
||||
Default: OpenInNewIcon,
|
||||
PieChart: PieChartIcon,
|
||||
Upgrade: UpgradeIcon,
|
||||
Router: RouterIcon,
|
||||
PriceCheck: PriceCheckIcon,
|
||||
RestartAlt: RestartAltIcon,
|
||||
// Add more icons as needed
|
||||
};
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Identifier } from "ra-core";
|
||||
|
||||
import { GetConfig } from "../utils/config";
|
||||
|
||||
const mxidPattern = /^@[^@:]+:[^@:]+$/;
|
||||
@@ -16,7 +17,11 @@ export const isMXID = (id: string | Identifier): boolean => mxidPattern.test(id
|
||||
* @returns Whether the user is managed by an application service
|
||||
*/
|
||||
export const isASManaged = (id: string | Identifier): boolean => {
|
||||
return GetConfig().asManagedUsers.some(regex => regex.test(id as string));
|
||||
const managedUsers = GetConfig().asManagedUsers;
|
||||
if (!managedUsers) {
|
||||
return false;
|
||||
}
|
||||
return managedUsers.some(regex => regex.test(id as string));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -38,15 +43,20 @@ export function generateRandomMXID(): string {
|
||||
* @returns full MXID as string
|
||||
*/
|
||||
export function returnMXID(input: string | Identifier): string {
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
const inputStr = input as string;
|
||||
const homeserver = localStorage.getItem("home_server") || "";
|
||||
|
||||
// when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address
|
||||
if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) {
|
||||
return inputStr; // Already a valid MXID
|
||||
}
|
||||
|
||||
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
|
||||
const mxidPattern = /^@[^@:]+:[^@:]+$/;
|
||||
if (isMXID(input)) {
|
||||
return input as string; // Already a valid MXID
|
||||
return inputStr; // Already a valid MXID
|
||||
}
|
||||
|
||||
// If input is not a valid MXID, assume it's a localpart and construct the MXID
|
||||
const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input;
|
||||
const localpart = typeof input === "string" && inputStr.startsWith("@") ? inputStr.slice(1) : inputStr;
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
||||
|
9
testdata/element/config.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"default_hs_url": "http://localhost:8008",
|
||||
"default_is_url": "https://vector.im",
|
||||
"integrations_ui_url": "https://scalar.vector.im/",
|
||||
"integrations_rest_url": "https://scalar.vector.im/api",
|
||||
"bug_report_endpoint_url": "https://riot.im/bugreports/submit",
|
||||
"enableLabs": true
|
||||
}
|
||||
|
34
testdata/element/nginx.conf
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
worker_processes 1;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /tmp/nginx.pid;
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
http {
|
||||
client_body_temp_path /tmp/client_body_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
4
testdata/synapse/homeserver.yaml
vendored
@@ -78,7 +78,7 @@ password_config:
|
||||
pid_file: /homeserver.pid
|
||||
presence:
|
||||
enabled: true
|
||||
public_baseurl: http://synapse:8008/
|
||||
public_baseurl: http://localhost:8008/
|
||||
push:
|
||||
include_content: true
|
||||
rc_admin_redaction:
|
||||
@@ -149,7 +149,7 @@ saml2_config:
|
||||
sp_config: null
|
||||
user_mapping_provider:
|
||||
config: null
|
||||
server_name: synapse
|
||||
server_name: synapse:8008
|
||||
signing_key_path: /config/synapse.signing.key
|
||||
spam_checker: []
|
||||
sso: null
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { vitePluginVersionMark } from "vite-plugin-version-mark";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { vitePluginVersionMark } from "vite-plugin-version-mark";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
@@ -16,7 +15,7 @@ export default defineConfig({
|
||||
ifMeta: false,
|
||||
ifLog: false,
|
||||
ifGlobal: true,
|
||||
outputFile: (version) => ({
|
||||
outputFile: version => ({
|
||||
path: "manifest.json",
|
||||
content: JSON.stringify({
|
||||
name: "Synapse Admin",
|
||||
@@ -24,20 +23,23 @@ export default defineConfig({
|
||||
description: "Synapse Admin is an admin console for synapse Matrix homeserver with additional features.",
|
||||
categories: ["productivity", "utilities"],
|
||||
orientation: "landscape",
|
||||
icons: [{
|
||||
src: "favicon.ico",
|
||||
sizes: "32x32",
|
||||
type: "image/x-icon"
|
||||
},{
|
||||
src: "images/logo.webp",
|
||||
sizes: "512x512",
|
||||
type: "image/webp",
|
||||
purpose: "any maskable"
|
||||
}],
|
||||
icons: [
|
||||
{
|
||||
src: "favicon.ico",
|
||||
sizes: "32x32",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
{
|
||||
src: "images/logo.webp",
|
||||
sizes: "512x512",
|
||||
type: "image/webp",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
start_url: ".",
|
||||
display: "standalone",
|
||||
theme_color: "#000000",
|
||||
background_color: "#ffffff"
|
||||
background_color: "#ffffff",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|