Compare commits
313 Commits
csv_import
...
master
Author | SHA1 | Date | |
---|---|---|---|
311db77306 | |||
7c08f846a5 | |||
f2f096d8a5 | |||
16191d9cc8 | |||
703129f88b | |||
|
c9364f631b | ||
|
08dc5f6271 | ||
|
c9cb9aa9e0 | ||
|
25020c2d5b | ||
|
1acffdb618 | ||
|
0b4f3a60c0 | ||
|
33d29e01b1 | ||
|
a2e47cb793 | ||
|
7deb9bcf7e | ||
|
8185d7f0b0 | ||
|
37e1fcc96d | ||
|
f6e193c51c | ||
|
fa1f86491f | ||
|
b6546b89ad | ||
|
baa8e4ad95 | ||
|
bd6d5847e1 | ||
|
32c5867e2a | ||
|
f68a5de64a | ||
|
e326599da2 | ||
|
8d6852ca8c | ||
|
ee859a2926 | ||
|
0c4ca1459f | ||
|
ebba0f66f7 | ||
|
7d4d765ab4 | ||
|
dfee94af96 | ||
|
ae7f6e18e5 | ||
|
d1e9f38b14 | ||
|
4054249359 | ||
|
f240318525 | ||
|
0852b54a8e | ||
|
8688ab7d0e | ||
|
abc677dc16 | ||
|
9d26a1ce3a | ||
|
3116b4e07a | ||
|
3a34c03509 | ||
|
384bc6553c | ||
|
df87432157 | ||
|
2afc7aeca4 | ||
|
ac843b3244 | ||
|
82155c23a1 | ||
|
64a89f6552 | ||
|
5b8882bd80 | ||
|
3fe0e95069 | ||
|
3cd0aa4446 | ||
|
3adc6b4663 | ||
|
76ef017244 | ||
|
00ecb29d6b | ||
|
4204eb902f | ||
|
6430aca02b | ||
|
b8a0b4bef5 | ||
|
2c769c309e | ||
|
82578c6570 | ||
|
005abfb4a2 | ||
|
155e73b9c6 | ||
|
1eb787fd9b | ||
|
691969e1a1 | ||
|
d520c6d618 | ||
|
af453eea71 | ||
|
78d1d34a84 | ||
|
a222af273f | ||
|
51def5775d | ||
|
6363e3d32e | ||
|
ba4345be1e | ||
|
b70ee7c55d | ||
|
9f03ec9b0f | ||
|
d8d393cdf6 | ||
|
58e02d6dff | ||
|
17379a7325 | ||
|
13fbc419c2 | ||
|
e757876c35 | ||
|
8b99695e60 | ||
|
4ab5ae2585 | ||
|
7579873d87 | ||
|
e2ce934f2a | ||
|
56bc4a56b9 | ||
|
4bae32b57c | ||
|
6e395e3b0f | ||
|
323ad9f9e2 | ||
|
50af6499b0 | ||
|
12c22a170b | ||
|
5bbb2fc77b | ||
|
499d15e667 | ||
|
bcf7dec10c | ||
|
e1c7f44fec | ||
|
0e25633398 | ||
|
8097fa4da4 | ||
|
ad9a1c502b | ||
|
ec2e7b2ccb | ||
|
8bdeddd8f6 | ||
|
e253fcb516 | ||
|
05a4f94d2f | ||
|
d747e24a96 | ||
|
35e3bbeb88 | ||
|
c750f6b8e9 | ||
|
0c5c762224 | ||
|
8fa78356a0 | ||
|
3fec64021a | ||
|
15528e3e9b | ||
|
39643bd8ab | ||
|
d592234d4e | ||
|
67500a6023 | ||
|
ffce8bd64c | ||
|
8b00f7d5ac | ||
|
bbd63f4838 | ||
|
bdf29afda0 | ||
|
46fa936f20 | ||
|
d502696c1f | ||
|
9b43d41040 | ||
|
3276a9b6ed | ||
|
b3a611e7ad | ||
|
de03e23524 | ||
|
a200a8932b | ||
|
41656748bb | ||
|
42a4decc2a | ||
|
74f77e6988 | ||
|
8501f19a03 | ||
|
6d30af9976 | ||
|
2e59190bd0 | ||
|
a647c91f4f | ||
|
21ba5c9862 | ||
|
0a701df5d2 | ||
|
347d1c3114 | ||
|
8982487eee | ||
|
7fa835f973 | ||
|
5767817733 | ||
|
e43f732bde | ||
|
c0f4a92f1a | ||
|
e70c680e8a | ||
|
3b4da1d3c2 | ||
|
3b2aa776eb | ||
|
ee8050f697 | ||
|
ca5fde9190 | ||
|
561daf7737 | ||
|
f2526dc00e | ||
|
3cce1a61d3 | ||
|
af3b5439a5 | ||
|
59afa49be0 | ||
|
bfd38f50ea | ||
|
37ed5c4156 | ||
|
2eb16eb747 | ||
|
bd6b6eef85 | ||
|
43f1b82d33 | ||
|
24afbd2953 | ||
|
a74a6166a3 | ||
|
e23e9ccd82 | ||
|
e787b0a940 | ||
|
67066a1ba7 | ||
|
08a7d5c0f6 | ||
|
472b13ec26 | ||
|
b901c9e0ff | ||
|
0b153ddcbb | ||
|
784c284723 | ||
|
e5f73ea8b4 | ||
|
96551259c5 | ||
|
b2fa533ef0 | ||
|
7aa0f9f50d | ||
|
5aa90c25f7 | ||
|
38d58db08d | ||
|
e637df232a | ||
|
d97c633cd0 | ||
|
b02396c61f | ||
|
95de50b925 | ||
|
1a150a10fd | ||
|
158e7dbe98 | ||
|
a642f11503 | ||
|
888a3f001b | ||
|
4b0845bee8 | ||
|
f449e3277a | ||
|
3303f253b4 | ||
|
0250954ee7 | ||
|
efed8b2774 | ||
|
c891afa611 | ||
|
38541b8f02 | ||
|
0f4c382c18 | ||
|
b90d4ef00f | ||
|
3fb33facc5 | ||
|
c4a68ff1d5 | ||
|
c4f0fa48ec | ||
|
26ed63d65e | ||
|
b9e81b2278 | ||
|
f6f437b17a | ||
|
91af8f1c04 | ||
|
abc9d5154e | ||
|
8228d7d2c2 | ||
|
4adc20f80d | ||
|
a5c7d7dd22 | ||
|
dc5c2c1d68 | ||
|
42b3252353 | ||
|
1a17d3e69b | ||
|
79ef38ee6b | ||
|
0ff4b30d71 | ||
|
6c4ff6c791 | ||
|
9c36ed6566 | ||
|
8536f552d4 | ||
|
aaf782d24f | ||
|
2886203594 | ||
|
865fc98336 | ||
|
e6f01f035b | ||
|
32e088ac5a | ||
|
bf3d13916f | ||
|
07b1df5855 | ||
|
634341ea07 | ||
|
361643c3da | ||
|
5262518699 | ||
|
78a282863a | ||
|
ff0201273a | ||
|
e50c95b4be | ||
|
9f16e5c6ba | ||
|
509a45cba4 | ||
|
5c6e5a9641 | ||
|
e3d5d51342 | ||
|
985673b161 | ||
|
d72357f64f | ||
|
e19c34324b | ||
|
3ea1f51eb5 | ||
|
229518e456 | ||
|
5a5a7143af | ||
|
dda8ba5e85 | ||
|
5208198b76 | ||
|
c8082a7198 | ||
|
10831796e3 | ||
|
5ee5288edf | ||
|
a5528d9fe7 | ||
|
e2fd934851 | ||
|
0bc1ce3226 | ||
|
41ce58bac8 | ||
|
57c41cc069 | ||
|
0e3375c5ad | ||
|
2cdd41b615 | ||
|
2ab4343970 | ||
|
0268cc0e94 | ||
|
f8331a459d | ||
|
a12cf95457 | ||
|
60854bcc60 | ||
|
62b3d094b7 | ||
|
81231b5ea6 | ||
|
c114e58278 | ||
|
c6b6e54617 | ||
|
8ff0ac913c | ||
|
96d2c96740 | ||
|
8adab0e927 | ||
|
536ffc2fbf | ||
|
684c44e470 | ||
|
7f92e1a3c0 | ||
|
ea59d0dd02 | ||
|
706114a382 | ||
|
f2a1275673 | ||
|
425c210cfc | ||
|
b184954ffa | ||
|
2f96951c19 | ||
|
1706cd3c9d | ||
|
eadc04a6a0 | ||
|
1432724a64 | ||
|
c841720f0c | ||
|
60ecafdf54 | ||
|
5de5015655 | ||
|
2bf8a6debb | ||
|
c8d9a6db55 | ||
|
e5d2d895d9 | ||
|
3d4f45d070 | ||
|
cfe4f4a995 | ||
|
04de65ce51 | ||
|
3d7c0bf67f | ||
|
4324ebfdb8 | ||
|
26b8cea6a5 | ||
|
8db881a64d | ||
|
c09e59d47a | ||
|
8bdf76f27e | ||
|
7c9a87bc86 | ||
|
1f56bac356 | ||
|
bbbca0c57c | ||
|
314906657f | ||
|
78e7c5f391 | ||
|
1074178e31 | ||
|
352ab1290a | ||
|
12447b7708 | ||
|
cd4efb7c07 | ||
|
ff59ee4c2e | ||
|
61938405e9 | ||
|
ac0657c428 | ||
|
ab709aee3e | ||
|
6da3c8b885 | ||
|
ab04db5baf | ||
|
8282a3caf8 | ||
|
2fc75cd6fc | ||
|
3fd615943c | ||
|
aaf1ebb909 | ||
|
627f3d2917 | ||
|
168e249296 | ||
|
5bdfb80db7 | ||
|
b7c3684b80 | ||
|
0ada5287d7 | ||
|
3884c50012 | ||
|
300e22a537 | ||
|
009ce803e2 | ||
|
c41b8ab846 | ||
|
7f16f784f9 | ||
|
1002b6464a | ||
|
50b770a312 | ||
|
2d0ce50444 | ||
|
1fb89c9e58 | ||
|
8a4c0fe0fe | ||
|
dd022eab04 | ||
|
437fd70d6d | ||
|
1e6e526e3c | ||
|
d812cff5fc | ||
|
a39033e25b | ||
|
b7f009e559 |
@ -1,5 +1,4 @@
|
||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
||||
.git/
|
||||
tests/
|
||||
build/
|
||||
lib/
|
||||
|
5
.env
Normal file
5
.env
Normal file
@ -0,0 +1,5 @@
|
||||
# This setting allows to fix the homeserver.
|
||||
# If you set this setting, the user will not be able to select
|
||||
# the server and have to use synapse-admin with this server.
|
||||
|
||||
#REACT_APP_SERVER=https://yourmatrixserver.example.com
|
20
.github/dependabot.yml
vendored
Normal file
20
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Major updates for react-admin have breaking changes
|
||||
- dependency-name: "react-admin"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
21
.github/workflows/build-test.yml
vendored
Normal file
21
.github/workflows/build-test.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: build-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: Install dependencies
|
||||
run: yarn --immutable
|
||||
- name: Run tests
|
||||
run: yarn test
|
63
.github/workflows/docker-release.yml
vendored
Normal file
63
.github/workflows/docker-release.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
name: Create docker image(s) and push to docker hub and ghcr.io
|
||||
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
|
||||
|
||||
on:
|
||||
push:
|
||||
# Sequence of patterns matched against refs/heads
|
||||
# prettier-ignore
|
||||
branches:
|
||||
# Push events on master branch
|
||||
- master
|
||||
# Sequence of patterns matched against refs/tags
|
||||
tags:
|
||||
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Push Docker image to multiple registries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
awesometechnologies/synapse-admin
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and Push Tag
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
28
.github/workflows/edge_ghpage.yml
vendored
Normal file
28
.github/workflows/edge_ghpage.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Build and Deploy Edge version to GH Pages
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: Install and Build 🔧
|
||||
run: |
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
|
||||
- name: Deploy 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@v4.5.0
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: build
|
33
.github/workflows/github-release.yml
vendored
Normal file
33
.github/workflows/github-release.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: Create release tarball and attach to tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
- run: yarn install --immutable
|
||||
- run: yarn build
|
||||
- run: |
|
||||
version=`git describe --dirty --tags || echo unknown`
|
||||
mkdir -p dist
|
||||
cp -r build synapse-admin-$version
|
||||
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
|
||||
- uses: softprops/action-gh-release@3198ee18f814cdf787321b4a32a26ddbf37acc52
|
||||
with:
|
||||
files: dist/*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
51
.github/workflows/test-docker-image.yml
vendored
Normal file
51
.github/workflows/test-docker-image.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
name: Test docker image creation
|
||||
|
||||
on:
|
||||
push:
|
||||
# Sequence of patterns matched against refs/heads
|
||||
# prettier-ignore
|
||||
branches:
|
||||
# Push events on branch fix_docker_cd
|
||||
- fix_docker_cd
|
||||
# Sequence of patterns matched against refs/tags
|
||||
tags:
|
||||
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Calculate docker image tag
|
||||
id: set-tag
|
||||
run: |
|
||||
case "${GITHUB_REF}" in
|
||||
refs/heads/master|refs/heads/main)
|
||||
tag=latest
|
||||
;;
|
||||
refs/tags/*)
|
||||
tag=${GITHUB_REF#refs/tags/}
|
||||
;;
|
||||
*)
|
||||
tag=${GITHUB_SHA}
|
||||
;;
|
||||
esac
|
||||
echo "::set-output name=tag::$tag"
|
||||
- name: Build and Push Tag
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
|
||||
platforms: linux/amd64,linux/arm64
|
@ -6,6 +6,6 @@
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 13
|
||||
|
||||
cache: yarn
|
@ -1,11 +1,13 @@
|
||||
# Builder
|
||||
FROM node:10-alpine as builder
|
||||
FROM node:lts as builder
|
||||
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
|
||||
ARG REACT_APP_SERVER
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . /src
|
||||
RUN yarn --network-timeout=100000 install
|
||||
RUN yarn build
|
||||
RUN yarn --network-timeout=300000 install --immutable
|
||||
RUN REACT_APP_SERVER=$REACT_APP_SERVER yarn build
|
||||
|
||||
|
||||
# App
|
||||
|
103
README.md
103
README.md
@ -1,9 +1,106 @@
|
||||
[![Build Status](https://travis-ci.org/Awesome-Technologies/synapse-admin.svg?branch=master)](https://travis-ci.org/Awesome-Technologies/synapse-admin)
|
||||
[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||
[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
|
||||
[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
|
||||
[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
|
||||
[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
|
||||
[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
|
||||
|
||||
# Synapse admin ui
|
||||
|
||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||
|
||||
Use `yarn install` after cloning this repo.
|
||||
## Usage
|
||||
|
||||
Use `yarn start` to launch the webserver.
|
||||
### Supported Synapse
|
||||
|
||||
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.52.0 for all functions to work as expected!
|
||||
|
||||
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
||||
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
||||
|
||||
After entering the URL on the login page of synapse-admin the server version appears below the input field.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You need access to the following endpoints:
|
||||
|
||||
- `/_matrix`
|
||||
- `/_synapse/admin`
|
||||
|
||||
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
|
||||
|
||||
### Use without install
|
||||
|
||||
You can use the current version of Synapse Admin without own installation direct
|
||||
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
|
||||
|
||||
**Note:**
|
||||
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
||||
**Remember: You have no need to expose these endpoints to the internet but to your network.**
|
||||
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
|
||||
|
||||
### Step-By-Step install
|
||||
|
||||
You have three options:
|
||||
|
||||
1. [Download the tarball and serve with any webserver](#steps-for-1)
|
||||
2. [Download the source code from github and run using nodejs](#steps-for-2)
|
||||
3. [Run the Docker container](#steps-for-3)
|
||||
|
||||
#### Steps for 1)
|
||||
|
||||
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
||||
- configure a vhost for synapse admin on your webserver
|
||||
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest
|
||||
- unpack the .tar.gz
|
||||
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
|
||||
- open the url of the vhost in your browser
|
||||
|
||||
#### Steps for 2)
|
||||
|
||||
- make sure you have installed the following: git, yarn, nodejs
|
||||
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
|
||||
- change into downloaded directory: `cd synapse-admin`
|
||||
- download dependencies: `yarn install`
|
||||
- start web server: `yarn start`
|
||||
|
||||
You can fix the homeserver, so that the user can no longer define it himself.
|
||||
Either you define it at startup (e.g. `REACT_APP_SERVER=https://yourmatrixserver.example.com yarn start`)
|
||||
or by editing it in the [.env](.env) file. See also the
|
||||
[documentation](https://create-react-app.dev/docs/adding-custom-environment-variables/).
|
||||
|
||||
#### Steps for 3)
|
||||
|
||||
- run the Docker container from the public docker registry: `docker run -p 8080:80 awesometechnologies/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
|
||||
|
||||
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
|
||||
|
||||
```yml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
synapse-admin:
|
||||
container_name: synapse-admin
|
||||
hostname: synapse-admin
|
||||
build:
|
||||
context: https://github.com/Awesome-Technologies/synapse-admin.git
|
||||
# args:
|
||||
# - NODE_OPTIONS="--max_old_space_size=1024"
|
||||
# # see #266, PUBLIC_URL must be without surrounding quotation marks
|
||||
# - PUBLIC_URL=/synapse-admin
|
||||
# - REACT_APP_SERVER="https://matrix.example.com"
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
- browse to http://localhost:8080
|
||||
|
||||
## Screenshots
|
||||
|
||||
![Screenshots](./screenshots.jpg)
|
||||
|
||||
## Development
|
||||
|
||||
- Use `yarn test` to run all style, lint and unit tests
|
||||
- Use `yarn fix` to fix the coding style
|
||||
|
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@ -0,0 +1,26 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
synapse-admin:
|
||||
container_name: synapse-admin
|
||||
hostname: synapse-admin
|
||||
image: awesometechnologies/synapse-admin:latest
|
||||
# build:
|
||||
# context: .
|
||||
|
||||
# to use the docker-compose as standalone without a local repo clone,
|
||||
# replace the context definition with this:
|
||||
# context: https://github.com/Awesome-Technologies/synapse-admin.git
|
||||
|
||||
# args:
|
||||
# if you're building on an architecture other than amd64, make sure
|
||||
# to define a maximum ram for node. otherwise the build will fail.
|
||||
# - NODE_OPTIONS="--max_old_space_size=1024"
|
||||
# default is .
|
||||
# - PUBLIC_URL=/synapse-admin
|
||||
# You can use a fixed homeserver, so that the user can no longer
|
||||
# define it himself
|
||||
# - REACT_APP_SERVER="https://matrix.example.com"
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
48
package.json
48
package.json
@ -1,40 +1,48 @@
|
||||
{
|
||||
"name": "synapse-admin",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.2",
|
||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": ".",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Awesome-Technologies/synapse-admin"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.1.1",
|
||||
"@testing-library/react": "^10.0.2",
|
||||
"@testing-library/user-event": "^10.0.1",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.10.1",
|
||||
"eslint-plugin-prettier": "^3.1.2",
|
||||
"prettier": "^2.0.0"
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"prettier": "^3.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"ra-language-german": "^2.1.2",
|
||||
"react": "^16.13.1",
|
||||
"react-admin": "^3.4.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-scripts": "^3.4.1",
|
||||
"react-admin-import-csv": "^0.2.5"
|
||||
"@mui/icons-material": "^5.15.15",
|
||||
"@mui/material": "^5.15.15",
|
||||
"@mui/styles": "^5.15.15",
|
||||
"papaparse": "^5.4.1",
|
||||
"ra-language-chinese": "^2.0.10",
|
||||
"ra-language-french": "^4.16.15",
|
||||
"ra-language-german": "^3.13.4",
|
||||
"ra-language-italian": "^3.13.1",
|
||||
"ra-language-farsi": "^4.2.0",
|
||||
"ra-language-russian": "^4.14.2",
|
||||
"react": "^18.0.0",
|
||||
"react-admin": "^4.16.15",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-scripts": "^5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",
|
||||
"build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build",
|
||||
"fix:other": "yarn prettier --write",
|
||||
"fix:code": "yarn test:lint --fix",
|
||||
"fix": "yarn fix:code && yarn fix:other",
|
||||
"prettier": "prettier \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
|
||||
"prettier": "prettier --ignore-path .gitignore \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
|
||||
"test:code": "react-scripts test",
|
||||
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
|
||||
"test:style": "yarn prettier --list-different",
|
||||
|
3
public/data/example.csv
Normal file
3
public/data/example.csv
Normal file
@ -0,0 +1,3 @@
|
||||
id,displayname,password,is_guest,admin,deactivated
|
||||
testuser22,Jane Doe,secretpassword,false,true,false
|
||||
,John Doe,,false,false,false
|
|
@ -38,5 +38,12 @@
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<footer
|
||||
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
||||
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
|
||||
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
|
||||
Synapse-Admin <b>(%REACT_APP_VERSION%)</b> by Awesome Technologies Innovationslabor GmbH
|
||||
</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -1,2 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
BIN
screenshots.jpg
Normal file
BIN
screenshots.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 222 KiB |
43
src/App.js
43
src/App.js
@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import { Admin, Resource, resolveBrowserLocale } from "react-admin";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import { UserList, UserCreate, UserEdit } from "./components/users";
|
||||
import { RoomList } from "./components/rooms";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import UserIcon from "@material-ui/icons/Group";
|
||||
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
|
||||
import germanMessages from "./i18n/de";
|
||||
import englishMessages from "./i18n/en";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
de: germanMessages,
|
||||
en: englishMessages,
|
||||
};
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
locale => (messages[locale] ? messages[locale] : messages.en),
|
||||
resolveBrowserLocale()
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
loginPage={LoginPage}
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
>
|
||||
<Resource
|
||||
name="users"
|
||||
list={UserList}
|
||||
create={UserCreate}
|
||||
edit={UserEdit}
|
||||
icon={UserIcon}
|
||||
/>
|
||||
<Resource name="rooms" list={RoomList} icon={RoomIcon} />
|
||||
<Resource name="connections" />
|
||||
</Admin>
|
||||
);
|
||||
|
||||
export default App;
|
72
src/App.jsx
Normal file
72
src/App.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Admin,
|
||||
CustomRoutes,
|
||||
Resource,
|
||||
resolveBrowserLocale,
|
||||
} from "react-admin";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import users from "./components/users";
|
||||
import rooms from "./components/rooms";
|
||||
import userMediaStats from "./components/statistics";
|
||||
import reports from "./components/EventReports";
|
||||
import roomDirectory from "./components/RoomDirectory";
|
||||
import destinations from "./components/destinations";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import { ImportFeature } from "./components/ImportFeature";
|
||||
import { Route } from "react-router-dom";
|
||||
import germanMessages from "./i18n/de";
|
||||
import englishMessages from "./i18n/en";
|
||||
import frenchMessages from "./i18n/fr";
|
||||
import chineseMessages from "./i18n/zh";
|
||||
import italianMessages from "./i18n/it";
|
||||
import russianMessages from "./i18n/ru";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
de: germanMessages,
|
||||
en: englishMessages,
|
||||
fr: frenchMessages,
|
||||
it: italianMessages,
|
||||
zh: chineseMessages,
|
||||
ru: russianMessages
|
||||
};
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
locale => (messages[locale] ? messages[locale] : messages.en),
|
||||
resolveBrowserLocale()
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
disableTelemetry
|
||||
requireAuth
|
||||
loginPage={LoginPage}
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
>
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<ImportFeature />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
<Resource {...userMediaStats} />
|
||||
<Resource {...reports} />
|
||||
<Resource {...roomDirectory} />
|
||||
<Resource {...destinations} />
|
||||
<Resource name="connections" />
|
||||
<Resource name="devices" />
|
||||
<Resource name="room_members" />
|
||||
<Resource name="users_media" />
|
||||
<Resource name="joined_rooms" />
|
||||
<Resource name="pushers" />
|
||||
<Resource name="servernotices" />
|
||||
<Resource name="forward_extremities" />
|
||||
<Resource name="room_state" />
|
||||
<Resource name="destination_rooms" />
|
||||
</Admin>
|
||||
);
|
||||
|
||||
export default App;
|
@ -1,14 +0,0 @@
|
||||
import React from "react";
|
||||
import { TestContext } from "react-admin";
|
||||
import { shallow } from "enzyme";
|
||||
import App from "./App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders", () => {
|
||||
shallow(
|
||||
<TestContext>
|
||||
<App />
|
||||
</TestContext>
|
||||
);
|
||||
});
|
||||
});
|
10
src/App.test.jsx
Normal file
10
src/App.test.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders", async () => {
|
||||
render(<App />);
|
||||
await screen.findAllByText("Welcome to Synapse-admin");
|
||||
});
|
||||
});
|
22
src/components/AvatarField.jsx
Normal file
22
src/components/AvatarField.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import get from "lodash/get";
|
||||
import { Avatar } from "@mui/material";
|
||||
import { useRecordContext } from "react-admin";
|
||||
|
||||
const AvatarField = ({ source, ...rest }) => {
|
||||
const record = useRecordContext(rest);
|
||||
const src = get(record, source)?.toString();
|
||||
const { alt, classes, sizes, sx, variant } = rest;
|
||||
return (
|
||||
<Avatar
|
||||
alt={alt}
|
||||
classes={classes}
|
||||
sizes={sizes}
|
||||
src={src}
|
||||
sx={sx}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarField;
|
18
src/components/AvatarField.test.js
Normal file
18
src/components/AvatarField.test.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { RecordContextProvider } from "react-admin";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import AvatarField from "./AvatarField";
|
||||
|
||||
describe("AvatarField", () => {
|
||||
it("shows image", () => {
|
||||
const value = {
|
||||
avatar: "foo",
|
||||
};
|
||||
render(
|
||||
<RecordContextProvider value={value}>
|
||||
<AvatarField source="avatar" />
|
||||
</RecordContextProvider>
|
||||
);
|
||||
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
|
||||
});
|
||||
});
|
148
src/components/EventReports.jsx
Normal file
148
src/components/EventReports.jsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
DeleteButton,
|
||||
List,
|
||||
NumberField,
|
||||
Pagination,
|
||||
ReferenceField,
|
||||
Show,
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import PageviewIcon from "@mui/icons-material/Pageview";
|
||||
import ReportIcon from "@mui/icons-material/Warning";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
|
||||
const date_format = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const ReportPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
export const ReportShow = props => {
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<Show {...props} actions={<ReportShowActions />}>
|
||||
<TabbedShowLayout>
|
||||
<Tab
|
||||
label={translate("synapseadmin.reports.tabs.basic", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
icon={<ViewListIcon />}
|
||||
>
|
||||
<DateField
|
||||
source="received_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={true}
|
||||
/>
|
||||
<ReferenceField source="user_id" reference="users">
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
<NumberField source="score" />
|
||||
<TextField source="reason" />
|
||||
<TextField source="name" />
|
||||
<TextField
|
||||
source="canonical_alias"
|
||||
label="resources.rooms.fields.canonical_alias"
|
||||
/>
|
||||
<ReferenceField
|
||||
source="room_id"
|
||||
reference="rooms"
|
||||
link="show"
|
||||
label="resources.rooms.fields.room_id"
|
||||
>
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label="synapseadmin.reports.tabs.detail"
|
||||
icon={<PageviewIcon />}
|
||||
path="detail"
|
||||
>
|
||||
<DateField
|
||||
source="event_json.origin_server_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={true}
|
||||
/>
|
||||
<ReferenceField source="sender" reference="users">
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
<TextField source="sender" label="Sender (raw user ID)" />
|
||||
<TextField source="event_id" />
|
||||
<TextField source="event_json.origin" />
|
||||
<TextField source="event_json.type" />
|
||||
<TextField source="event_json.content.msgtype" />
|
||||
<TextField source="event_json.content.body" />
|
||||
<TextField source="event_json.content.format" />
|
||||
<TextField source="event_json.content.formatted_body" />
|
||||
<TextField source="event_json.content.algorithm" />
|
||||
<TextField
|
||||
source="event_json.content.device_id"
|
||||
label="resources.devices.fields.device_id"
|
||||
/>
|
||||
</Tab>
|
||||
</TabbedShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportShowActions = () => {
|
||||
const record = useRecordContext();
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
<DeleteButton
|
||||
record={record}
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle="resources.reports.action.erase.title"
|
||||
confirmContent="resources.reports.action.erase.content"
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReportList = props => (
|
||||
<List
|
||||
{...props}
|
||||
pagination={<ReportPagination />}
|
||||
sort={{ field: "received_ts", order: "DESC" }}
|
||||
>
|
||||
<Datagrid rowClick="show" bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} />
|
||||
<DateField
|
||||
source="received_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={true}
|
||||
/>
|
||||
<TextField sortable={false} source="user_id" />
|
||||
<TextField sortable={false} source="name" />
|
||||
<TextField sortable={false} source="score" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
const resource = {
|
||||
name: "reports",
|
||||
icon: ReportIcon,
|
||||
list: ReportList,
|
||||
show: ReportShow,
|
||||
};
|
||||
|
||||
export default resource;
|
655
src/components/ImportFeature.jsx
Normal file
655
src/components/ImportFeature.jsx
Normal file
@ -0,0 +1,655 @@
|
||||
import React, { useState } from "react";
|
||||
import { useDataProvider, useNotify, Title } from "react-admin";
|
||||
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Container,
|
||||
FormControlLabel,
|
||||
NativeSelect,
|
||||
} from "@mui/material";
|
||||
import { useTranslate } from "ra-core";
|
||||
import { generateRandomUser } from "./users";
|
||||
|
||||
const LOGGING = true;
|
||||
|
||||
const expectedFields = ["id", "displayname"].sort();
|
||||
const optionalFields = [
|
||||
"user_type",
|
||||
"guest",
|
||||
"admin",
|
||||
"deactivated",
|
||||
"avatar_url",
|
||||
"password",
|
||||
].sort();
|
||||
|
||||
function TranslatableOption({ value, text }) {
|
||||
const translate = useTranslate();
|
||||
return <option value={value}>{translate(text)}</option>;
|
||||
}
|
||||
|
||||
const FilePicker = () => {
|
||||
const [values, setValues] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
|
||||
const [progress, setProgress] = useState(null);
|
||||
|
||||
const [importResults, setImportResults] = useState(null);
|
||||
const [skippedRecords, setSkippedRecords] = useState(null);
|
||||
|
||||
const [conflictMode, setConflictMode] = useState("stop");
|
||||
const [passwordMode, setPasswordMode] = useState(true);
|
||||
const [useridMode, setUseridMode] = useState("ignore");
|
||||
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
const onFileChange = async e => {
|
||||
if (progress !== null) return;
|
||||
|
||||
setValues(null);
|
||||
setError(null);
|
||||
setStats(null);
|
||||
setImportResults(null);
|
||||
const file = e.target.files ? e.target.files[0] : null;
|
||||
/* 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(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true /* especially for a final EOL in the csv file */,
|
||||
complete: result => {
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
/* Papaparse is very lenient, we may be able to salvage
|
||||
* the data in the file. */
|
||||
verifyCsv(result, { setValues, setStats, setError });
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setError(true);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCsv = (
|
||||
{ data, meta, errors },
|
||||
{ setValues, setStats, setError }
|
||||
) => {
|
||||
/* First, verify the presence of required fields */
|
||||
let eF = Array.from(expectedFields);
|
||||
let oF = Array.from(optionalFields);
|
||||
|
||||
meta.fields.forEach(name => {
|
||||
if (eF.includes(name)) {
|
||||
eF = eF.filter(v => v !== name);
|
||||
}
|
||||
if (oF.includes(name)) {
|
||||
oF = oF.filter(v => v !== name);
|
||||
}
|
||||
});
|
||||
|
||||
if (eF.length !== 0) {
|
||||
setError(
|
||||
translate("import_users.error.required_field", { field: eF[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.
|
||||
*/
|
||||
let stats = {
|
||||
user_types: { default: 0 },
|
||||
is_guest: 0,
|
||||
admin: 0,
|
||||
deactivated: 0,
|
||||
password: 0,
|
||||
avatar_url: 0,
|
||||
id: 0,
|
||||
|
||||
total: data.length,
|
||||
};
|
||||
|
||||
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")) {
|
||||
line.admin = line.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] !== "") {
|
||||
errors.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 (errors.length > 0) {
|
||||
setError(errors);
|
||||
}
|
||||
setStats(stats);
|
||||
setValues(data);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const runImport = async _e => {
|
||||
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,
|
||||
data,
|
||||
conflictMode,
|
||||
passwordMode,
|
||||
useridMode,
|
||||
dryRun,
|
||||
setProgress,
|
||||
setError
|
||||
) => {
|
||||
let skippedRecords = [];
|
||||
let erroredRecords = [];
|
||||
let succeededRecords = [];
|
||||
let changeStats = {
|
||||
toAdmin: 0,
|
||||
toGuest: 0,
|
||||
toRegular: 0,
|
||||
replacedPassword: 0,
|
||||
};
|
||||
let entriesDone = 0;
|
||||
let entriesCount = data.length;
|
||||
try {
|
||||
setProgress({ done: entriesDone, limit: entriesCount });
|
||||
for (const entry of data) {
|
||||
let userRecord = {};
|
||||
let overwriteData = {};
|
||||
// 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" ||
|
||||
entry.id === undefined ||
|
||||
entry.password === undefined ||
|
||||
passwordMode === false
|
||||
) {
|
||||
overwriteData = generateRandomUser();
|
||||
// Ignoring IDs or the entry lacking an ID means we keep the
|
||||
// ID field in the overwrite data.
|
||||
if (!(useridMode === "ignore" || entry.id === undefined)) {
|
||||
delete overwriteData.id;
|
||||
}
|
||||
|
||||
// Not using passwords from the csv or this entry lacking a password
|
||||
// means we keep the password field in the overwrite data.
|
||||
if (
|
||||
!(
|
||||
passwordMode === false ||
|
||||
entry.password === undefined ||
|
||||
entry.password === ""
|
||||
)
|
||||
) {
|
||||
delete overwriteData.password;
|
||||
}
|
||||
}
|
||||
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
||||
Object.assign(userRecord, entry);
|
||||
Object.assign(userRecord, overwriteData);
|
||||
|
||||
/* 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 => {
|
||||
return dataProvider.getOne("users", { id: recordData.id }).then(
|
||||
async _alreadyExists => {
|
||||
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 overwriteData = generateRandomUser();
|
||||
const newRecordData = Object.assign({}, recordData, {
|
||||
id: overwriteData.id,
|
||||
});
|
||||
retries++;
|
||||
if (retries > 512) {
|
||||
console.warn("retry loop got stuck? pathological situation?");
|
||||
skippedRecords.push(recordData);
|
||||
} else {
|
||||
await submitRecord(newRecordData);
|
||||
}
|
||||
}
|
||||
},
|
||||
async _okToSubmit => {
|
||||
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.message,
|
||||
})
|
||||
);
|
||||
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 => {
|
||||
if (progress !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = e.target.value;
|
||||
setConflictMode(value);
|
||||
};
|
||||
|
||||
const onPasswordModeChange = e => {
|
||||
if (progress !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordMode(e.target.checked);
|
||||
};
|
||||
|
||||
const onUseridModeChanged = async e => {
|
||||
if (progress !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = e.target.value;
|
||||
setUseridMode(value);
|
||||
};
|
||||
|
||||
const onDryRunModeChanged = ev => {
|
||||
if (progress !== null) {
|
||||
return;
|
||||
}
|
||||
setDryRun(ev.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}
|
||||
enabled={(progress !== null).toString()}
|
||||
>
|
||||
<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}
|
||||
enabled={(progress !== null).toString()}
|
||||
onChange={onPasswordModeChange}
|
||||
/>
|
||||
}
|
||||
label={translate("import_users.cards.passwords.use_passwords")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</CardContent>
|
||||
</Container>,
|
||||
];
|
||||
|
||||
let conflictCards = stats && !importResults && (
|
||||
<Container>
|
||||
<CardHeader title={translate("import_users.cards.conflicts.header")} />
|
||||
<CardContent>
|
||||
<div>
|
||||
<NativeSelect
|
||||
onChange={onConflictModeChanged}
|
||||
value={conflictMode}
|
||||
enabled={(progress !== null).toString()}
|
||||
>
|
||||
<TranslatableOption
|
||||
value="stop"
|
||||
text="import_users.cards.conflicts.mode.stop"
|
||||
/>
|
||||
<TranslatableOption
|
||||
value="skip"
|
||||
text="import_users.cards.conflicts.mode.skip"
|
||||
/>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Container>
|
||||
);
|
||||
|
||||
let errorCards = error && (
|
||||
<Container>
|
||||
<CardHeader title={translate("import_users.error.error")} />
|
||||
<CardContent>
|
||||
{(Array.isArray(error) ? error : [error]).map(e => (
|
||||
<div>{e}</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Container>
|
||||
);
|
||||
|
||||
let 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}
|
||||
enabled={(progress !== null).toString()}
|
||||
/>
|
||||
</CardContent>
|
||||
</Container>
|
||||
);
|
||||
|
||||
let 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>
|
||||
);
|
||||
|
||||
let startImportCard =
|
||||
!values || values.length === 0 || importResults ? undefined : (
|
||||
<CardActions>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={dryRun}
|
||||
onChange={onDryRunModeChanged}
|
||||
enabled={(progress !== null).toString()}
|
||||
/>
|
||||
}
|
||||
label={translate("import_users.cards.startImport.simulate_only")}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={runImport}
|
||||
enabled={(progress !== null).toString()}
|
||||
>
|
||||
{translate("import_users.cards.startImport.run_import")}
|
||||
</Button>
|
||||
{progress !== null ? (
|
||||
<div>
|
||||
{progress.done} of {progress.limit} done
|
||||
</div>
|
||||
) : null}
|
||||
</CardActions>
|
||||
);
|
||||
|
||||
let allCards = [];
|
||||
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);
|
||||
|
||||
let cardContainer = <Card>{allCards}</Card>;
|
||||
|
||||
return [
|
||||
<Title defaultTitle={translate("import_users.title")} />,
|
||||
cardContainer,
|
||||
];
|
||||
};
|
||||
|
||||
export const ImportFeature = FilePicker;
|
@ -1,198 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Notification,
|
||||
useLogin,
|
||||
useNotify,
|
||||
useLocale,
|
||||
useSetLocale,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
} from "@material-ui/core";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import LockIcon from "@material-ui/icons/Lock";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
main: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
background: "url(./images/floating-cogs.svg)",
|
||||
backgroundColor: "#f9f9f9",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
marginTop: "6em",
|
||||
},
|
||||
avatar: {
|
||||
margin: "1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
},
|
||||
icon: {
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
},
|
||||
hint: {
|
||||
marginTop: "1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
color: theme.palette.grey[500],
|
||||
},
|
||||
form: {
|
||||
padding: "0 1em 1em 1em",
|
||||
},
|
||||
input: {
|
||||
marginTop: "1em",
|
||||
},
|
||||
actions: {
|
||||
padding: "0 1em 1em 1em",
|
||||
},
|
||||
}));
|
||||
|
||||
const LoginPage = ({ theme }) => {
|
||||
const classes = useStyles({ theme });
|
||||
const login = useLogin();
|
||||
const notify = useNotify();
|
||||
const [loading, setLoading] = useState(false);
|
||||
var locale = useLocale();
|
||||
const setLocale = useSetLocale();
|
||||
const translate = useTranslate();
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
|
||||
const renderInput = ({
|
||||
meta: { touched, error } = {},
|
||||
input: { ...inputProps },
|
||||
...props
|
||||
}) => (
|
||||
<TextField
|
||||
error={!!(touched && error)}
|
||||
helperText={touched && error}
|
||||
{...inputProps}
|
||||
{...props}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
|
||||
const validate = values => {
|
||||
const errors = {};
|
||||
if (!values.homeserver) {
|
||||
errors.homeserver = translate("ra.validation.required");
|
||||
}
|
||||
if (!values.username) {
|
||||
errors.username = translate("ra.validation.required");
|
||||
}
|
||||
if (!values.password) {
|
||||
errors.password = translate("ra.validation.required");
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSubmit = auth => {
|
||||
setLoading(true);
|
||||
login(auth).catch(error => {
|
||||
setLoading(false);
|
||||
notify(
|
||||
typeof error === "string"
|
||||
? error
|
||||
: typeof error === "undefined" || !error.message
|
||||
? "ra.auth.sign_in_error"
|
||||
: error.message,
|
||||
"warning"
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initialValues={{ homeserver: homeserver }}
|
||||
onSubmit={handleSubmit}
|
||||
validate={validate}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className={classes.main}>
|
||||
<Card className={classes.card}>
|
||||
<div className={classes.avatar}>
|
||||
<Avatar className={classes.icon}>
|
||||
<LockIcon />
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className={classes.hint}>
|
||||
{translate("synapseadmin.auth.welcome")}
|
||||
</div>
|
||||
<div className={classes.form}>
|
||||
<div className={classes.input}>
|
||||
<Select
|
||||
value={locale}
|
||||
onChange={e => {
|
||||
setLocale(e.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
</Select>
|
||||
</div>
|
||||
<div className={classes.input}>
|
||||
<Field
|
||||
autoFocus
|
||||
name="homeserver"
|
||||
component={renderInput}
|
||||
label={translate("synapseadmin.auth.homeserver")}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.input}>
|
||||
<Field
|
||||
name="username"
|
||||
component={renderInput}
|
||||
label={translate("ra.auth.username")}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.input}>
|
||||
<Field
|
||||
name="password"
|
||||
component={renderInput}
|
||||
label={translate("ra.auth.password")}
|
||||
type="password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardActions className={classes.actions}>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
className={classes.button}
|
||||
fullWidth
|
||||
>
|
||||
{loading && <CircularProgress size={25} thickness={2} />}
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
<Notification />
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
329
src/components/LoginPage.jsx
Normal file
329
src/components/LoginPage.jsx
Normal file
@ -0,0 +1,329 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormDataConsumer,
|
||||
Notification,
|
||||
required,
|
||||
useLogin,
|
||||
useNotify,
|
||||
useLocaleState,
|
||||
useTranslate,
|
||||
PasswordInput,
|
||||
TextInput,
|
||||
} from "react-admin";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import {
|
||||
getServerVersion,
|
||||
getSupportedFeatures,
|
||||
getSupportedLoginFlows,
|
||||
getWellKnownUrl,
|
||||
isValidBaseUrl,
|
||||
splitMxid,
|
||||
} from "../synapse/synapse";
|
||||
|
||||
const FormBox = styled(Box)(({ theme }) => ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "calc(100vh - 1rem)",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
background: "url(./images/floating-cogs.svg)",
|
||||
backgroundColor: "#f9f9f9",
|
||||
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",
|
||||
},
|
||||
}));
|
||||
|
||||
const LoginPage = () => {
|
||||
const login = useLogin();
|
||||
const notify = useNotify();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supportPassAuth, setSupportPassAuth] = useState(true);
|
||||
const [locale, setLocale] = useLocaleState();
|
||||
const translate = useTranslate();
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const cfg_base_url = process.env.REACT_APP_SERVER;
|
||||
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
||||
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
||||
|
||||
if (loginToken) {
|
||||
const ssoToken = loginToken[1];
|
||||
console.log("SSO token is", ssoToken);
|
||||
// Prevent further requests
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
window.location.href.replace(loginToken[0], "#").split("#")[0]
|
||||
);
|
||||
const baseUrl = localStorage.getItem("sso_base_url");
|
||||
localStorage.removeItem("sso_base_url");
|
||||
if (baseUrl) {
|
||||
const auth = {
|
||||
base_url: baseUrl,
|
||||
username: null,
|
||||
password: null,
|
||||
loginToken: ssoToken,
|
||||
};
|
||||
console.log("Base URL is:", baseUrl);
|
||||
console.log("SSO Token is:", ssoToken);
|
||||
console.log("Let's try token login...");
|
||||
login(auth).catch(error => {
|
||||
alert(
|
||||
typeof error === "string"
|
||||
? error
|
||||
: typeof error === "undefined" || !error.message
|
||||
? "ra.auth.sign_in_error"
|
||||
: error.message
|
||||
);
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const validateBaseUrl = value => {
|
||||
if (!value.match(/^(http|https):\/\//)) {
|
||||
return translate("synapseadmin.auth.protocol_error");
|
||||
} else if (
|
||||
!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)
|
||||
) {
|
||||
return translate("synapseadmin.auth.url_error");
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = auth => {
|
||||
setLoading(true);
|
||||
login(auth).catch(error => {
|
||||
setLoading(false);
|
||||
notify(
|
||||
typeof error === "string"
|
||||
? error
|
||||
: typeof error === "undefined" || !error.message
|
||||
? "ra.auth.sign_in_error"
|
||||
: error.message,
|
||||
{ type: "warning" }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSSO = () => {
|
||||
localStorage.setItem("sso_base_url", ssoBaseUrl);
|
||||
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
|
||||
window.location.href
|
||||
)}`;
|
||||
window.location.href = ssoFullUrl;
|
||||
};
|
||||
|
||||
const UserData = ({ formData }) => {
|
||||
const form = useFormContext();
|
||||
const [serverVersion, setServerVersion] = useState("");
|
||||
const [matrixVersions, setMatrixVersions] = useState("");
|
||||
|
||||
const handleUsernameChange = _ => {
|
||||
if (formData.base_url || cfg_base_url) return;
|
||||
// check if username is a full qualified userId then set base_url accordingly
|
||||
const domain = splitMxid(formData.username)?.domain;
|
||||
if (domain) {
|
||||
getWellKnownUrl(domain).then(url => form.setValue("base_url", url));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidBaseUrl(formData.base_url)) return;
|
||||
|
||||
getServerVersion(formData.base_url)
|
||||
.then(serverVersion =>
|
||||
setServerVersion(
|
||||
`${translate("synapseadmin.auth.server_version")} ${serverVersion}`
|
||||
)
|
||||
)
|
||||
.catch(() => setServerVersion(""));
|
||||
|
||||
getSupportedFeatures(formData.base_url)
|
||||
.then(features =>
|
||||
setMatrixVersions(
|
||||
`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`
|
||||
)
|
||||
)
|
||||
.catch(() => setMatrixVersions(""));
|
||||
|
||||
// Set SSO Url
|
||||
getSupportedLoginFlows(formData.base_url)
|
||||
.then(loginFlows => {
|
||||
const supportPass =
|
||||
loginFlows.find(f => f.type === "m.login.password") !== undefined;
|
||||
const supportSSO =
|
||||
loginFlows.find(f => f.type === "m.login.sso") !== undefined;
|
||||
setSupportPassAuth(supportPass);
|
||||
setSSOBaseUrl(supportSSO ? formData.base_url : "");
|
||||
})
|
||||
.catch(() => setSSOBaseUrl(""));
|
||||
}, [formData.base_url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<TextInput
|
||||
autoFocus
|
||||
name="username"
|
||||
label="ra.auth.username"
|
||||
disabled={loading || !supportPassAuth}
|
||||
onBlur={handleUsernameChange}
|
||||
resettable
|
||||
fullWidth
|
||||
className="input"
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
label="ra.auth.password"
|
||||
type="password"
|
||||
disabled={loading || !supportPassAuth}
|
||||
resettable
|
||||
fullWidth
|
||||
className="input"
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<TextInput
|
||||
name="base_url"
|
||||
label="synapseadmin.auth.base_url"
|
||||
disabled={cfg_base_url || loading}
|
||||
resettable
|
||||
fullWidth
|
||||
className="input"
|
||||
validate={[required(), validateBaseUrl]}
|
||||
/>
|
||||
</Box>
|
||||
<Typography className="serverVersion">{serverVersion}</Typography>
|
||||
<Typography className="matrixVersions">{matrixVersions}</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
defaultValues={{ base_url: cfg_base_url || base_url }}
|
||||
onSubmit={handleSubmit}
|
||||
mode="onTouched"
|
||||
>
|
||||
<FormBox>
|
||||
<Card className="card">
|
||||
<Box className="avatar">
|
||||
{loading ? (
|
||||
<CircularProgress size={25} thickness={2} />
|
||||
) : (
|
||||
<Avatar className="icon">
|
||||
<LockIcon />
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||
<Box className="form">
|
||||
<Select
|
||||
value={locale}
|
||||
onChange={e => {
|
||||
setLocale(e.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
className="select"
|
||||
>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="fr">Français</MenuItem>
|
||||
<MenuItem value="it">Italiano</MenuItem>
|
||||
<MenuItem value="zh">简体中文</MenuItem>
|
||||
<MenuItem value="fa">Persian(فارسی)</MenuItem>
|
||||
<MenuItem value="ru">Русский</MenuItem>
|
||||
</Select>
|
||||
<FormDataConsumer>
|
||||
{formDataProps => <UserData {...formDataProps} />}
|
||||
</FormDataConsumer>
|
||||
<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>
|
||||
</Box>
|
||||
</Card>
|
||||
</FormBox>
|
||||
<Notification />
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { TestContext } from "react-admin";
|
||||
import { shallow } from "enzyme";
|
||||
import { render } from "@testing-library/react";
|
||||
import { AdminContext } from "react-admin";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
describe("LoginForm", () => {
|
||||
it("renders", () => {
|
||||
shallow(
|
||||
<TestContext>
|
||||
render(
|
||||
<AdminContext>
|
||||
<LoginPage />
|
||||
</TestContext>
|
||||
</AdminContext>
|
||||
);
|
||||
});
|
||||
});
|
142
src/components/RegistrationTokens.jsx
Normal file
142
src/components/RegistrationTokens.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BooleanInput,
|
||||
Create,
|
||||
Datagrid,
|
||||
DateField,
|
||||
DateTimeInput,
|
||||
Edit,
|
||||
List,
|
||||
maxValue,
|
||||
number,
|
||||
NumberField,
|
||||
NumberInput,
|
||||
regex,
|
||||
SaveButton,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
TextField,
|
||||
Toolbar,
|
||||
} from "react-admin";
|
||||
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
|
||||
|
||||
const date_format = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
|
||||
const validateUsesAllowed = [number()];
|
||||
const validateLength = [number(), maxValue(64)];
|
||||
|
||||
const dateParser = v => {
|
||||
const d = new Date(v);
|
||||
if (isNaN(d)) return 0;
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
const dateFormatter = v => {
|
||||
if (v === undefined || v === null) return;
|
||||
const d = new Date(v);
|
||||
|
||||
const pad = "00";
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
|
||||
const day = (pad + d.getDate().toString()).slice(-2);
|
||||
const hour = (pad + d.getHours().toString()).slice(-2);
|
||||
const minute = (pad + d.getMinutes().toString()).slice(-2);
|
||||
|
||||
// target format yyyy-MM-ddThh:mm
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
};
|
||||
|
||||
const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
|
||||
|
||||
export const RegistrationTokenList = props => (
|
||||
<List
|
||||
{...props}
|
||||
filters={registrationTokenFilters}
|
||||
filterDefaultValues={{ valid: true }}
|
||||
pagination={false}
|
||||
perPage={500}
|
||||
>
|
||||
<Datagrid rowClick="edit">
|
||||
<TextField source="token" sortable={false} />
|
||||
<NumberField source="uses_allowed" sortable={false} />
|
||||
<NumberField source="pending" sortable={false} />
|
||||
<NumberField source="completed" sortable={false} />
|
||||
<DateField
|
||||
source="expiry_time"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={false}
|
||||
/>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const RegistrationTokenCreate = props => (
|
||||
<Create {...props} redirect="list">
|
||||
<SimpleForm
|
||||
toolbar={
|
||||
<Toolbar>
|
||||
{/* It is possible to create tokens per default without input. */}
|
||||
<SaveButton alwaysEnable />
|
||||
</Toolbar>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
source="token"
|
||||
autoComplete="off"
|
||||
validate={validateToken}
|
||||
resettable
|
||||
/>
|
||||
<NumberInput
|
||||
source="length"
|
||||
validate={validateLength}
|
||||
helperText="resources.registration_tokens.helper.length"
|
||||
step={1}
|
||||
/>
|
||||
<NumberInput
|
||||
source="uses_allowed"
|
||||
validate={validateUsesAllowed}
|
||||
step={1}
|
||||
/>
|
||||
<DateTimeInput source="expiry_time" parse={dateParser} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const RegistrationTokenEdit = props => (
|
||||
<Edit {...props}>
|
||||
<SimpleForm>
|
||||
<TextInput source="token" disabled />
|
||||
<NumberInput source="pending" disabled />
|
||||
<NumberInput source="completed" disabled />
|
||||
<NumberInput
|
||||
source="uses_allowed"
|
||||
validate={validateUsesAllowed}
|
||||
step={1}
|
||||
/>
|
||||
<DateTimeInput
|
||||
source="expiry_time"
|
||||
parse={dateParser}
|
||||
format={dateFormatter}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
const resource = {
|
||||
name: "registration_tokens",
|
||||
icon: RegistrationTokenIcon,
|
||||
list: RegistrationTokenList,
|
||||
edit: RegistrationTokenEdit,
|
||||
create: RegistrationTokenCreate,
|
||||
};
|
||||
|
||||
export default resource;
|
206
src/components/RoomDirectory.jsx
Normal file
206
src/components/RoomDirectory.jsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BooleanField,
|
||||
BulkDeleteButton,
|
||||
Button,
|
||||
DatagridConfigurable,
|
||||
ExportButton,
|
||||
DeleteButton,
|
||||
List,
|
||||
NumberField,
|
||||
Pagination,
|
||||
SelectColumnsButton,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useCreate,
|
||||
useDataProvider,
|
||||
useListContext,
|
||||
useNotify,
|
||||
useTranslate,
|
||||
useRecordContext,
|
||||
useRefresh,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "react-query";
|
||||
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
|
||||
import AvatarField from "./AvatarField";
|
||||
|
||||
const RoomDirectoryPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />
|
||||
);
|
||||
|
||||
export const RoomDirectoryUnpublishButton = props => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<DeleteButton
|
||||
{...props}
|
||||
label="resources.room_directory.action.erase"
|
||||
redirect={false}
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle={translate("resources.room_directory.action.title", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
confirmContent={translate("resources.room_directory.action.content", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
resource="room_directory"
|
||||
icon={<RoomDirectoryIcon />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomDirectoryBulkUnpublishButton = props => (
|
||||
<BulkDeleteButton
|
||||
{...props}
|
||||
label="resources.room_directory.action.erase"
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle="resources.room_directory.action.title"
|
||||
confirmContent="resources.room_directory.action.content"
|
||||
resource="room_directory"
|
||||
icon={<RoomDirectoryIcon />}
|
||||
/>
|
||||
);
|
||||
|
||||
export const RoomDirectoryBulkPublishButton = props => {
|
||||
const { selectedIds } = useListContext();
|
||||
const notify = useNotify();
|
||||
const refresh = useRefresh();
|
||||
const unselectAllRooms = useUnselectAll("rooms");
|
||||
const dataProvider = useDataProvider();
|
||||
const { mutate, isLoading } = useMutation(
|
||||
() =>
|
||||
dataProvider.createMany("room_directory", {
|
||||
ids: selectedIds,
|
||||
data: {},
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.room_directory.action.send_success");
|
||||
unselectAllRooms();
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.room_directory.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
label="resources.room_directory.action.create"
|
||||
onClick={mutate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RoomDirectoryIcon />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomDirectoryPublishButton = props => {
|
||||
const record = useRecordContext();
|
||||
const notify = useNotify();
|
||||
const refresh = useRefresh();
|
||||
const [create, { isLoading }] = useCreate();
|
||||
|
||||
const handleSend = () => {
|
||||
create(
|
||||
"room_directory",
|
||||
{ data: { id: record.id } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.room_directory.action.send_success");
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.room_directory.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
label="resources.room_directory.action.create"
|
||||
onClick={handleSend}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RoomDirectoryIcon />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomDirectoryListActions = () => (
|
||||
<TopToolbar>
|
||||
<SelectColumnsButton />
|
||||
<ExportButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
|
||||
export const RoomDirectoryList = () => (
|
||||
<List
|
||||
pagination={<RoomDirectoryPagination />}
|
||||
perPage={100}
|
||||
actions={<RoomDirectoryListActions />}
|
||||
>
|
||||
<DatagridConfigurable
|
||||
rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"}
|
||||
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
|
||||
omit={["room_id", "canonical_alias", "topic"]}
|
||||
>
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sortable={false}
|
||||
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"
|
||||
/>
|
||||
<TextField
|
||||
source="topic"
|
||||
sortable={false}
|
||||
label="resources.rooms.fields.topic"
|
||||
/>
|
||||
<NumberField
|
||||
source="num_joined_members"
|
||||
sortable={false}
|
||||
label="resources.rooms.fields.joined_members"
|
||||
/>
|
||||
<BooleanField
|
||||
source="world_readable"
|
||||
sortable={false}
|
||||
label="resources.room_directory.fields.world_readable"
|
||||
/>
|
||||
<BooleanField
|
||||
source="guest_can_join"
|
||||
sortable={false}
|
||||
label="resources.room_directory.fields.guest_can_join"
|
||||
/>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
const resource = {
|
||||
name: "room_directory",
|
||||
icon: RoomDirectoryIcon,
|
||||
list: RoomDirectoryList,
|
||||
};
|
||||
|
||||
export default resource;
|
155
src/components/ServerNotices.jsx
Normal file
155
src/components/ServerNotices.jsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
SaveButton,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Toolbar,
|
||||
required,
|
||||
useCreate,
|
||||
useDataProvider,
|
||||
useListContext,
|
||||
useNotify,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "react-query";
|
||||
import MessageIcon from "@mui/icons-material/Message";
|
||||
import IconCancel from "@mui/icons-material/Cancel";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
|
||||
const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const ServerNoticeToolbar = props => (
|
||||
<Toolbar {...props}>
|
||||
<SaveButton
|
||||
label="resources.servernotices.action.send"
|
||||
disabled={props.pristine}
|
||||
/>
|
||||
<Button label="ra.action.cancel" onClick={onClose}>
|
||||
<IconCancel />
|
||||
</Button>
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} loading={loading}>
|
||||
<DialogTitle>
|
||||
{translate("resources.servernotices.action.send")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{translate("resources.servernotices.helper.send")}
|
||||
</DialogContentText>
|
||||
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
source="body"
|
||||
label="resources.servernotices.fields.body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows="4"
|
||||
resettable
|
||||
validate={required()}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServerNoticeButton = () => {
|
||||
const record = useRecordContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const notify = useNotify();
|
||||
const [create, { isloading }] = useCreate();
|
||||
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleSend = values => {
|
||||
create(
|
||||
"servernotices",
|
||||
{ data: { id: record.id, ...values } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.servernotices.action.send_success");
|
||||
handleDialogClose();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.servernotices.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label="resources.servernotices.send"
|
||||
onClick={handleDialogOpen}
|
||||
disabled={isloading}
|
||||
>
|
||||
<MessageIcon />
|
||||
</Button>
|
||||
<ServerNoticeDialog
|
||||
open={open}
|
||||
onClose={handleDialogClose}
|
||||
onSubmit={handleSend}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServerNoticeBulkButton = () => {
|
||||
const { selectedIds } = useListContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openDialog = () => setOpen(true);
|
||||
const closeDialog = () => setOpen(false);
|
||||
const notify = useNotify();
|
||||
const unselectAllUsers = useUnselectAll("users");
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
const { mutate: sendNotices, isLoading } = useMutation(
|
||||
data =>
|
||||
dataProvider.createMany("servernotices", {
|
||||
ids: selectedIds,
|
||||
data: data,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.servernotices.action.send_success");
|
||||
unselectAllUsers();
|
||||
closeDialog();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.servernotices.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label="resources.servernotices.send"
|
||||
onClick={openDialog}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MessageIcon />
|
||||
</Button>
|
||||
<ServerNoticeDialog
|
||||
open={open}
|
||||
onClose={closeDialog}
|
||||
onSubmit={sendNotices}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
189
src/components/destinations.jsx
Normal file
189
src/components/destinations.jsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Datagrid,
|
||||
DateField,
|
||||
List,
|
||||
Pagination,
|
||||
ReferenceField,
|
||||
ReferenceManyField,
|
||||
SearchInput,
|
||||
Show,
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useDelete,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import DestinationsIcon from "@mui/icons-material/CloudQueue";
|
||||
import FolderSharedIcon from "@mui/icons-material/FolderShared";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
|
||||
const DestinationPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
const date_format = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const destinationRowSx = (record, _index) => ({
|
||||
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
|
||||
});
|
||||
|
||||
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
|
||||
|
||||
export const DestinationReconnectButton = () => {
|
||||
const record = useRecordContext();
|
||||
const refresh = useRefresh();
|
||||
const notify = useNotify();
|
||||
const [handleReconnect, { isLoading }] = useDelete();
|
||||
|
||||
// Reconnect is not required if no error has occurred. (`failure_ts`)
|
||||
if (!record || !record.failure_ts) return null;
|
||||
|
||||
const handleClick = e => {
|
||||
// Prevents redirection to the detail page when clicking in the list
|
||||
e.stopPropagation();
|
||||
|
||||
handleReconnect(
|
||||
"destinations",
|
||||
{ id: record.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("ra.notification.updated", {
|
||||
messageArgs: { smart_count: 1 },
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
onError: () => {
|
||||
notify("ra.message.error", { type: "error" });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
label="resources.destinations.action.reconnect"
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<AutorenewIcon />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const DestinationShowActions = () => (
|
||||
<TopToolbar>
|
||||
<DestinationReconnectButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
|
||||
const DestinationTitle = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<span>
|
||||
{translate("resources.destinations.name", 1)} {record.destination}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const DestinationList = props => {
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
filters={destinationFilters}
|
||||
pagination={<DestinationPagination />}
|
||||
sort={{ field: "destination", order: "ASC" }}
|
||||
>
|
||||
<Datagrid
|
||||
rowSx={destinationRowSx}
|
||||
rowClick={(id, _resource, _record) => `${id}/show/rooms`}
|
||||
bulkActionButtons={false}
|
||||
>
|
||||
<TextField source="destination" />
|
||||
<DateField source="failure_ts" showTime options={date_format} />
|
||||
<DateField source="retry_last_ts" showTime options={date_format} />
|
||||
<TextField source="retry_interval" />
|
||||
<TextField source="last_successful_stream_ordering" />
|
||||
<DestinationReconnectButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const DestinationShow = props => {
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<Show
|
||||
actions={<DestinationShowActions />}
|
||||
title={<DestinationTitle />}
|
||||
{...props}
|
||||
>
|
||||
<TabbedShowLayout>
|
||||
<Tab label="status" icon={<ViewListIcon />}>
|
||||
<TextField source="destination" />
|
||||
<DateField source="failure_ts" showTime options={date_format} />
|
||||
<DateField source="retry_last_ts" showTime options={date_format} />
|
||||
<TextField source="retry_interval" />
|
||||
<TextField source="last_successful_stream_ordering" />
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label={translate("resources.rooms.name", { smart_count: 2 })}
|
||||
icon={<FolderSharedIcon />}
|
||||
path="rooms"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="destination_rooms"
|
||||
target="destination"
|
||||
addLabel={false}
|
||||
pagination={<DestinationPagination />}
|
||||
perPage={50}
|
||||
>
|
||||
<Datagrid
|
||||
style={{ width: "100%" }}
|
||||
rowClick={(id, resource, record) => `/rooms/${id}/show`}
|
||||
>
|
||||
<TextField
|
||||
source="room_id"
|
||||
label="resources.rooms.fields.room_id"
|
||||
/>
|
||||
<TextField source="stream_ordering" sortable={false} />
|
||||
<ReferenceField
|
||||
label="resources.rooms.fields.name"
|
||||
source="id"
|
||||
reference="rooms"
|
||||
sortable={false}
|
||||
link=""
|
||||
>
|
||||
<TextField source="name" sortable={false} />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
</TabbedShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const resource = {
|
||||
name: "destinations",
|
||||
icon: DestinationsIcon,
|
||||
list: DestinationList,
|
||||
show: DestinationShow,
|
||||
};
|
||||
|
||||
export default resource;
|
51
src/components/devices.jsx
Normal file
51
src/components/devices.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DeleteButton,
|
||||
useDelete,
|
||||
useNotify,
|
||||
useRecordContext,
|
||||
useRefresh,
|
||||
} from "react-admin";
|
||||
|
||||
export const DeviceRemoveButton = props => {
|
||||
const record = useRecordContext();
|
||||
const refresh = useRefresh();
|
||||
const notify = useNotify();
|
||||
|
||||
const [removeDevice] = useDelete();
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
removeDevice(
|
||||
"devices",
|
||||
// needs previousData for user_id
|
||||
{ id: record.id, previousData: record },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.devices.action.erase.success");
|
||||
refresh();
|
||||
},
|
||||
onError: () => {
|
||||
notify("resources.devices.action.erase.failure", { type: "error" });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteButton
|
||||
{...props}
|
||||
label="ra.action.remove"
|
||||
confirmTitle="resources.devices.action.erase.title"
|
||||
confirmContent="resources.devices.action.erase.content"
|
||||
onConfirm={handleConfirm}
|
||||
mutationMode="pessimistic"
|
||||
redirect={false}
|
||||
translateOptions={{
|
||||
id: record.id,
|
||||
name: record.display_name ? record.display_name : record.id,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
335
src/components/media.jsx
Normal file
335
src/components/media.jsx
Normal file
@ -0,0 +1,335 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
BooleanInput,
|
||||
Button,
|
||||
DateTimeInput,
|
||||
NumberInput,
|
||||
SaveButton,
|
||||
SimpleForm,
|
||||
Toolbar,
|
||||
useCreate,
|
||||
useDelete,
|
||||
useNotify,
|
||||
useRecordContext,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import IconCancel from "@mui/icons-material/Cancel";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const dateParser = v => {
|
||||
const d = new Date(v);
|
||||
if (isNaN(d)) return 0;
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
const DeleteMediaToolbar = props => (
|
||||
<Toolbar {...props}>
|
||||
<SaveButton
|
||||
label="resources.delete_media.action.send"
|
||||
icon={<DeleteSweepIcon />}
|
||||
/>
|
||||
<Button label="ra.action.cancel" onClick={onClose}>
|
||||
<IconCancel />
|
||||
</Button>
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} loading={loading}>
|
||||
<DialogTitle>
|
||||
{translate("resources.delete_media.action.send")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{translate("resources.delete_media.helper.send")}
|
||||
</DialogContentText>
|
||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||
<DateTimeInput
|
||||
fullWidth
|
||||
source="before_ts"
|
||||
label="resources.delete_media.fields.before_ts"
|
||||
defaultValue={0}
|
||||
parse={dateParser}
|
||||
/>
|
||||
<NumberInput
|
||||
fullWidth
|
||||
source="size_gt"
|
||||
label="resources.delete_media.fields.size_gt"
|
||||
defaultValue={0}
|
||||
min={0}
|
||||
step={1024}
|
||||
/>
|
||||
<BooleanInput
|
||||
fullWidth
|
||||
source="keep_profiles"
|
||||
label="resources.delete_media.fields.keep_profiles"
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteMediaButton = props => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const notify = useNotify();
|
||||
const [deleteOne, { isLoading }] = useDelete();
|
||||
|
||||
const openDialog = () => setOpen(true);
|
||||
const closeDialog = () => setOpen(false);
|
||||
|
||||
const deleteMedia = values => {
|
||||
deleteOne(
|
||||
"delete_media",
|
||||
// needs meta.before_ts, meta.size_gt and meta.keep_profiles
|
||||
{ meta: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.delete_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.delete_media.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
{...props}
|
||||
label="resources.delete_media.action.send"
|
||||
onClick={openDialog}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
||||
// Reset on mouse devices
|
||||
"@media (hover: none)": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteSweepIcon />
|
||||
</Button>
|
||||
<DeleteMediaDialog
|
||||
open={open}
|
||||
onClose={closeDialog}
|
||||
onSubmit={deleteMedia}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProtectMediaButton = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
const refresh = useRefresh();
|
||||
const notify = useNotify();
|
||||
const [create, { isLoading }] = useCreate();
|
||||
const [deleteOne] = useDelete();
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const handleProtect = () => {
|
||||
create(
|
||||
"protect_media",
|
||||
{ data: record },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.protect_media.action.send_success");
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.protect_media.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnprotect = () => {
|
||||
deleteOne(
|
||||
"protect_media",
|
||||
{ id: record.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.protect_media.action.send_success");
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.protect_media.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
/*
|
||||
Wrapping Tooltip with <div>
|
||||
https://github.com/marmelab/react-admin/issues/4349#issuecomment-578594735
|
||||
*/
|
||||
<>
|
||||
{record.quarantined_by && (
|
||||
<Tooltip
|
||||
title={translate("resources.protect_media.action.none", {
|
||||
_: "resources.protect_media.action.none",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{/*
|
||||
Button instead BooleanField for
|
||||
consistent appearance and position in the column
|
||||
*/}
|
||||
<Button disabled={true}>
|
||||
<ClearIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{record.safe_from_quarantine && (
|
||||
<Tooltip
|
||||
title={translate("resources.protect_media.action.delete", {
|
||||
_: "resources.protect_media.action.delete",
|
||||
})}
|
||||
arrow
|
||||
>
|
||||
<div>
|
||||
<Button onClick={handleUnprotect} disabled={isLoading}>
|
||||
<LockIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!record.safe_from_quarantine && !record.quarantined_by && (
|
||||
<Tooltip
|
||||
title={translate("resources.protect_media.action.create", {
|
||||
_: "resources.protect_media.action.create",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Button onClick={handleProtect} disabled={isLoading}>
|
||||
<LockOpenIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuarantineMediaButton = props => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
const refresh = useRefresh();
|
||||
const notify = useNotify();
|
||||
const [create, { isLoading }] = useCreate();
|
||||
const [deleteOne] = useDelete();
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const handleQuarantaine = () => {
|
||||
create(
|
||||
"quarantine_media",
|
||||
{ data: record },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.quarantine_media.action.send_success");
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.quarantine_media.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveQuarantaine = () => {
|
||||
deleteOne(
|
||||
"quarantine_media",
|
||||
{ id: record.id, previousData: record },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.quarantine_media.action.send_success");
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.quarantine_media.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{record.safe_from_quarantine && (
|
||||
<Tooltip
|
||||
title={translate("resources.quarantine_media.action.none", {
|
||||
_: "resources.quarantine_media.action.none",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Button {...props} disabled={true}>
|
||||
<ClearIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{record.quarantined_by && (
|
||||
<Tooltip
|
||||
title={translate("resources.quarantine_media.action.delete", {
|
||||
_: "resources.quarantine_media.action.delete",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={handleRemoveQuarantaine}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<BlockIcon color="error" />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!record.safe_from_quarantine && !record.quarantined_by && (
|
||||
<Tooltip
|
||||
title={translate("resources.quarantine_media.action.create", {
|
||||
_: "resources.quarantine_media.action.create",
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Button onClick={handleQuarantaine} disabled={isLoading}>
|
||||
<BlockIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import React from "react";
|
||||
import { Datagrid, List, TextField, Pagination } from "react-admin";
|
||||
|
||||
const RoomPagination = props => (
|
||||
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
export const RoomList = props => (
|
||||
<List {...props} pagination={<RoomPagination />}>
|
||||
<Datagrid>
|
||||
<TextField source="room_id" />
|
||||
<TextField source="name" />
|
||||
<TextField source="canonical_alias" />
|
||||
<TextField source="joined_members" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
355
src/components/rooms.jsx
Normal file
355
src/components/rooms.jsx
Normal file
@ -0,0 +1,355 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BooleanField,
|
||||
BulkDeleteButton,
|
||||
DateField,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DeleteButton,
|
||||
ExportButton,
|
||||
FunctionField,
|
||||
List,
|
||||
NumberField,
|
||||
Pagination,
|
||||
ReferenceField,
|
||||
ReferenceManyField,
|
||||
SearchInput,
|
||||
SelectColumnsButton,
|
||||
SelectField,
|
||||
Show,
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import FastForwardIcon from "@mui/icons-material/FastForward";
|
||||
import HttpsIcon from "@mui/icons-material/Https";
|
||||
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
|
||||
import PageviewIcon from "@mui/icons-material/Pageview";
|
||||
import UserIcon from "@mui/icons-material/Group";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EventIcon from "@mui/icons-material/Event";
|
||||
import RoomIcon from "@mui/icons-material/ViewList";
|
||||
import {
|
||||
RoomDirectoryBulkUnpublishButton,
|
||||
RoomDirectoryBulkPublishButton,
|
||||
RoomDirectoryUnpublishButton,
|
||||
RoomDirectoryPublishButton,
|
||||
} from "./RoomDirectory";
|
||||
|
||||
const date_format = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const RoomPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
const RoomTitle = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
var name = "";
|
||||
if (record) {
|
||||
name = record.name !== "" ? record.name : record.id;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{translate("resources.rooms.name", 1)} {name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomShowActions = () => {
|
||||
const record = useRecordContext();
|
||||
var roomDirectoryStatus = "";
|
||||
if (record) {
|
||||
roomDirectoryStatus = record.public;
|
||||
}
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
{roomDirectoryStatus === false && <RoomDirectoryPublishButton />}
|
||||
{roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
|
||||
<DeleteButton
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomShow = props => {
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
||||
<TabbedShowLayout>
|
||||
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
||||
<TextField source="room_id" />
|
||||
<TextField source="name" />
|
||||
<TextField source="topic" />
|
||||
<TextField source="canonical_alias" />
|
||||
<ReferenceField source="creator" reference="users">
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label="synapseadmin.rooms.tabs.detail"
|
||||
icon={<PageviewIcon />}
|
||||
path="detail"
|
||||
>
|
||||
<TextField source="joined_members" />
|
||||
<TextField source="joined_local_members" />
|
||||
<TextField source="joined_local_devices" />
|
||||
<TextField source="state_events" />
|
||||
<TextField source="version" />
|
||||
<TextField
|
||||
source="encryption"
|
||||
emptyText={translate("resources.rooms.enums.unencrypted")}
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label="synapseadmin.rooms.tabs.members"
|
||||
icon={<UserIcon />}
|
||||
path="members"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="room_members"
|
||||
target="room_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid
|
||||
style={{ width: "100%" }}
|
||||
rowClick={(id, resource, record) => "/users/" + id}
|
||||
bulkActionButtons={false}
|
||||
>
|
||||
<TextField
|
||||
source="id"
|
||||
sortable={false}
|
||||
label="resources.users.fields.id"
|
||||
/>
|
||||
<ReferenceField
|
||||
label="resources.users.fields.displayname"
|
||||
source="id"
|
||||
reference="users"
|
||||
sortable={false}
|
||||
link=""
|
||||
>
|
||||
<TextField source="displayname" sortable={false} />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label="synapseadmin.rooms.tabs.permission"
|
||||
icon={<VisibilityIcon />}
|
||||
path="permission"
|
||||
>
|
||||
<BooleanField source="federatable" />
|
||||
<BooleanField source="public" />
|
||||
<SelectField
|
||||
source="join_rules"
|
||||
choices={[
|
||||
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
|
||||
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
|
||||
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
|
||||
{
|
||||
id: "private",
|
||||
name: "resources.rooms.enums.join_rules.private",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
source="guest_access"
|
||||
choices={[
|
||||
{
|
||||
id: "can_join",
|
||||
name: "resources.rooms.enums.guest_access.can_join",
|
||||
},
|
||||
{
|
||||
id: "forbidden",
|
||||
name: "resources.rooms.enums.guest_access.forbidden",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
source="history_visibility"
|
||||
choices={[
|
||||
{
|
||||
id: "invited",
|
||||
name: "resources.rooms.enums.history_visibility.invited",
|
||||
},
|
||||
{
|
||||
id: "joined",
|
||||
name: "resources.rooms.enums.history_visibility.joined",
|
||||
},
|
||||
{
|
||||
id: "shared",
|
||||
name: "resources.rooms.enums.history_visibility.shared",
|
||||
},
|
||||
{
|
||||
id: "world_readable",
|
||||
name: "resources.rooms.enums.history_visibility.world_readable",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label={translate("resources.room_state.name", { smart_count: 2 })}
|
||||
icon={<EventIcon />}
|
||||
path="state"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="room_state"
|
||||
target="room_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="type" sortable={false} />
|
||||
<DateField
|
||||
source="origin_server_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={false}
|
||||
/>
|
||||
<TextField source="content" sortable={false} />
|
||||
<ReferenceField
|
||||
source="sender"
|
||||
reference="users"
|
||||
sortable={false}
|
||||
>
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
label="resources.forward_extremities.name"
|
||||
icon={<FastForwardIcon />}
|
||||
path="forward_extremities"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
margin: "0.5em",
|
||||
}}
|
||||
>
|
||||
{translate("resources.rooms.helper.forward_extremities")}
|
||||
</Box>
|
||||
<ReferenceManyField
|
||||
reference="forward_extremities"
|
||||
target="room_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} />
|
||||
<DateField
|
||||
source="received_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={false}
|
||||
/>
|
||||
<NumberField source="depth" sortable={false} />
|
||||
<TextField source="state_group" sortable={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
</TabbedShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomBulkActionButtons = () => (
|
||||
<>
|
||||
<RoomDirectoryBulkPublishButton />
|
||||
<RoomDirectoryBulkUnpublishButton />
|
||||
<BulkDeleteButton
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
||||
|
||||
const RoomListActions = () => (
|
||||
<TopToolbar>
|
||||
<SelectColumnsButton />
|
||||
<ExportButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
|
||||
export const RoomList = props => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
pagination={<RoomPagination />}
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
filters={roomFilters}
|
||||
actions={<RoomListActions />}
|
||||
>
|
||||
<DatagridConfigurable
|
||||
rowClick="show"
|
||||
bulkActionButtons={<RoomBulkActionButtons />}
|
||||
omit={[
|
||||
"joined_local_members",
|
||||
"state_events",
|
||||
"version",
|
||||
"federatable",
|
||||
]}
|
||||
>
|
||||
<BooleanField
|
||||
source="is_encrypted"
|
||||
sortBy="encryption"
|
||||
TrueIcon={HttpsIcon}
|
||||
FalseIcon={NoEncryptionIcon}
|
||||
label={<HttpsIcon />}
|
||||
sx={{
|
||||
[`& [data-testid="true"]`]: { color: theme.palette.success.main },
|
||||
[`& [data-testid="false"]`]: { color: theme.palette.error.main },
|
||||
}}
|
||||
/>
|
||||
<FunctionField
|
||||
source="name"
|
||||
render={record =>
|
||||
record["name"] || record["canonical_alias"] || record["id"]
|
||||
}
|
||||
/>
|
||||
<TextField source="joined_members" />
|
||||
<TextField source="joined_local_members" />
|
||||
<TextField source="state_events" />
|
||||
<TextField source="version" />
|
||||
<BooleanField source="federatable" />
|
||||
<BooleanField source="public" />
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
const resource = {
|
||||
name: "rooms",
|
||||
icon: RoomIcon,
|
||||
list: RoomList,
|
||||
show: RoomShow,
|
||||
};
|
||||
|
||||
export default resource;
|
79
src/components/statistics.jsx
Normal file
79
src/components/statistics.jsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { cloneElement } from "react";
|
||||
import {
|
||||
Datagrid,
|
||||
ExportButton,
|
||||
List,
|
||||
NumberField,
|
||||
Pagination,
|
||||
sanitizeListRestProps,
|
||||
SearchInput,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useListContext,
|
||||
} from "react-admin";
|
||||
import EqualizerIcon from "@mui/icons-material/Equalizer";
|
||||
import { DeleteMediaButton } from "./media";
|
||||
|
||||
const ListActions = props => {
|
||||
const { className, exporter, filters, maxResults, ...rest } = props;
|
||||
const { sort, resource, displayedFilters, filterValues, showFilter, total } =
|
||||
useListContext();
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: "button",
|
||||
})}
|
||||
<DeleteMediaButton />
|
||||
<ExportButton
|
||||
disabled={total === 0}
|
||||
resource={resource}
|
||||
sort={sort}
|
||||
filterValues={filterValues}
|
||||
maxResults={maxResults}
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMediaStatsPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
|
||||
|
||||
export const UserMediaStatsList = props => (
|
||||
<List
|
||||
{...props}
|
||||
actions={<ListActions />}
|
||||
filters={userMediaStatsFilters}
|
||||
pagination={<UserMediaStatsPagination />}
|
||||
sort={{ field: "media_length", order: "DESC" }}
|
||||
>
|
||||
<Datagrid
|
||||
rowClick={(id, resource, record) => "/users/" + id + "/media"}
|
||||
bulkActionButtons={false}
|
||||
>
|
||||
<TextField source="user_id" label="resources.users.fields.id" />
|
||||
<TextField
|
||||
source="displayname"
|
||||
label="resources.users.fields.displayname"
|
||||
/>
|
||||
<NumberField source="media_count" />
|
||||
<NumberField source="media_length" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
const resource = {
|
||||
name: "user_media_statistics",
|
||||
icon: EqualizerIcon,
|
||||
list: UserMediaStatsList,
|
||||
};
|
||||
|
||||
export default resource;
|
@ -1,197 +0,0 @@
|
||||
import React from "react";
|
||||
import PersonPinIcon from "@material-ui/icons/PersonPin";
|
||||
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
Datagrid,
|
||||
DateField,
|
||||
Create,
|
||||
Edit,
|
||||
List,
|
||||
Filter,
|
||||
SimpleForm,
|
||||
SimpleFormIterator,
|
||||
TabbedForm,
|
||||
FormTab,
|
||||
BooleanField,
|
||||
BooleanInput,
|
||||
ImageField,
|
||||
PasswordInput,
|
||||
TextField,
|
||||
TextInput,
|
||||
ReferenceField,
|
||||
Toolbar,
|
||||
TopToolbar,
|
||||
SelectInput,
|
||||
regex,
|
||||
Pagination,
|
||||
} from "react-admin";
|
||||
import { ImportButton } from "react-admin-import-csv";
|
||||
import { CreateButton, ExportButton } from "ra-ui-materialui";
|
||||
|
||||
const UserPagination = props => (
|
||||
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
const UserFilter = props => (
|
||||
<Filter {...props}>
|
||||
<BooleanInput source="guests" alwaysOn />
|
||||
<BooleanInput
|
||||
label="resources.users.fields.show_deactivated"
|
||||
source="deactivated"
|
||||
alwaysOn
|
||||
/>
|
||||
</Filter>
|
||||
);
|
||||
|
||||
const ListActions = props => {
|
||||
const {
|
||||
className,
|
||||
basePath,
|
||||
total,
|
||||
resource,
|
||||
currentSort,
|
||||
filterValues,
|
||||
exporter
|
||||
} = props;
|
||||
return (
|
||||
<TopToolbar className={className}>
|
||||
<CreateButton basePath={basePath} />
|
||||
<ImportButton {...props} />
|
||||
<ExportButton
|
||||
disabled={total === 0}
|
||||
resource={resource}
|
||||
sort={currentSort}
|
||||
filter={filterValues}
|
||||
exporter={exporter}
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserList = props => (
|
||||
<List
|
||||
{...props}
|
||||
filters={<UserFilter />}
|
||||
filterDefaultValues={{ guests: true, deactivated: false }}
|
||||
bulkActionButtons={false}
|
||||
pagination={<UserPagination />}
|
||||
actions={<ListActions />}
|
||||
>
|
||||
<Datagrid rowClick="edit">
|
||||
<ReferenceField
|
||||
source="Avatar"
|
||||
reference="users"
|
||||
link={false}
|
||||
sortable={false}
|
||||
>
|
||||
<ImageField source="avatar_url" title="displayname" />
|
||||
</ReferenceField>
|
||||
<TextField source="id" />
|
||||
{/* Hack since the users endpoint does not give displaynames in the list*/}
|
||||
<ReferenceField
|
||||
source="name"
|
||||
reference="users"
|
||||
link={false}
|
||||
sortable={false}
|
||||
>
|
||||
<TextField source="displayname" />
|
||||
</ReferenceField>
|
||||
<BooleanField source="is_guest" sortable={false} />
|
||||
<BooleanField source="admin" sortable={false} />
|
||||
<BooleanField source="deactivated" sortable={false} />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
// https://matrix.org/docs/spec/appendices#user-identifiers
|
||||
const validateUser = regex(
|
||||
/^@[a-z0-9._=\-/]+:.*/,
|
||||
"synapseadmin.users.invalid_user_id"
|
||||
);
|
||||
|
||||
export const UserCreate = props => (
|
||||
<Create {...props}>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||
<TextInput source="displayname" />
|
||||
<PasswordInput source="password" autoComplete="new-password" />
|
||||
<BooleanInput source="admin" />
|
||||
<ArrayInput source="threepids">
|
||||
<SimpleFormIterator>
|
||||
<SelectInput
|
||||
source="medium"
|
||||
choices={[
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
{ id: "msisdn", name: "resources.users.msisdn" },
|
||||
]}
|
||||
/>
|
||||
<TextInput source="address" />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const UserEdit = props => (
|
||||
<Edit {...props}>
|
||||
<TabbedForm>
|
||||
<FormTab label="resources.users.name" icon={<PersonPinIcon />}>
|
||||
<TextInput source="id" disabled />
|
||||
<TextInput source="displayname" />
|
||||
<PasswordInput source="password" autoComplete="new-password" />
|
||||
<BooleanInput source="admin" />
|
||||
<BooleanInput
|
||||
source="deactivated"
|
||||
helperText="resources.users.helper.deactivate"
|
||||
/>
|
||||
<ArrayInput source="threepids">
|
||||
<SimpleFormIterator>
|
||||
<SelectInput
|
||||
source="medium"
|
||||
choices={[
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
{ id: "msisdn", name: "resources.users.msisdn" },
|
||||
]}
|
||||
/>
|
||||
<TextInput source="address" />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</FormTab>
|
||||
<FormTab
|
||||
label="resources.connections.name"
|
||||
icon={<SettingsInputComponentIcon />}
|
||||
>
|
||||
<ReferenceField reference="connections" source="id" addLabel={false}>
|
||||
<ArrayField
|
||||
source="devices[].sessions[0].connections"
|
||||
label="resources.connections.name"
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }}>
|
||||
<TextField source="ip" sortable={false} />
|
||||
<DateField
|
||||
source="last_seen"
|
||||
showTime
|
||||
options={{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}}
|
||||
sortable={false}
|
||||
/>
|
||||
<TextField
|
||||
source="user_agent"
|
||||
sortable={false}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Datagrid>
|
||||
</ArrayField>
|
||||
</ReferenceField>
|
||||
</FormTab>
|
||||
</TabbedForm>
|
||||
</Edit>
|
||||
);
|
538
src/components/users.jsx
Normal file
538
src/components/users.jsx
Normal file
@ -0,0 +1,538 @@
|
||||
import React, { cloneElement } from "react";
|
||||
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
|
||||
import ContactMailIcon from "@mui/icons-material/ContactMail";
|
||||
import DevicesIcon from "@mui/icons-material/Devices";
|
||||
import GetAppIcon from "@mui/icons-material/GetApp";
|
||||
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 UserIcon from "@mui/icons-material/Group";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
Button,
|
||||
Datagrid,
|
||||
DateField,
|
||||
Create,
|
||||
Edit,
|
||||
List,
|
||||
Toolbar,
|
||||
SimpleForm,
|
||||
SimpleFormIterator,
|
||||
TabbedForm,
|
||||
FormTab,
|
||||
BooleanField,
|
||||
BooleanInput,
|
||||
PasswordInput,
|
||||
TextField,
|
||||
TextInput,
|
||||
ReferenceField,
|
||||
ReferenceManyField,
|
||||
SearchInput,
|
||||
SelectInput,
|
||||
BulkDeleteButton,
|
||||
DeleteButton,
|
||||
SaveButton,
|
||||
maxLength,
|
||||
regex,
|
||||
required,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
Pagination,
|
||||
CreateButton,
|
||||
ExportButton,
|
||||
TopToolbar,
|
||||
sanitizeListRestProps,
|
||||
NumberField,
|
||||
} from "react-admin";
|
||||
import { Link } from "react-router-dom";
|
||||
import AvatarField from "./AvatarField";
|
||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
|
||||
import { DeviceRemoveButton } from "./devices";
|
||||
import { ProtectMediaButton, QuarantineMediaButton } from "./media";
|
||||
|
||||
const choices_medium = [
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
{ id: "msisdn", name: "resources.users.msisdn" },
|
||||
];
|
||||
|
||||
const choices_type = [
|
||||
{ id: "bot", name: "bot" },
|
||||
{ id: "support", name: "support" },
|
||||
];
|
||||
|
||||
const date_format = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const UserListActions = ({
|
||||
sort,
|
||||
className,
|
||||
resource,
|
||||
filters,
|
||||
displayedFilters,
|
||||
exporter, // you can hide ExportButton if exporter = (null || false)
|
||||
filterValues,
|
||||
permanentFilter,
|
||||
hasCreate, // you can hide CreateButton if hasCreate = false
|
||||
selectedIds,
|
||||
onUnselectItems,
|
||||
showFilter,
|
||||
maxResults,
|
||||
total,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
showFilter,
|
||||
displayedFilters,
|
||||
filterValues,
|
||||
context: "button",
|
||||
})}
|
||||
<CreateButton />
|
||||
<ExportButton
|
||||
disabled={total === 0}
|
||||
resource={resource}
|
||||
sort={sort}
|
||||
filter={{ ...filterValues, ...permanentFilter }}
|
||||
exporter={exporter}
|
||||
maxResults={maxResults}
|
||||
/>
|
||||
{/* Add your custom actions */}
|
||||
<Button component={Link} to="/import_users" label="CSV Import">
|
||||
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
|
||||
</Button>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
UserListActions.defaultProps = {
|
||||
selectedIds: [],
|
||||
onUnselectItems: () => null,
|
||||
};
|
||||
|
||||
const UserPagination = () => (
|
||||
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||
);
|
||||
|
||||
const userFilters = [
|
||||
<SearchInput source="name" alwaysOn />,
|
||||
<BooleanInput source="guests" alwaysOn />,
|
||||
<BooleanInput
|
||||
label="resources.users.fields.show_deactivated"
|
||||
source="deactivated"
|
||||
alwaysOn
|
||||
/>,
|
||||
];
|
||||
|
||||
const UserBulkActionButtons = () => (
|
||||
<>
|
||||
<ServerNoticeBulkButton />
|
||||
<BulkDeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const UserList = props => (
|
||||
<List
|
||||
{...props}
|
||||
filters={userFilters}
|
||||
filterDefaultValues={{ guests: true, deactivated: false }}
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
actions={<UserListActions maxResults={10000} />}
|
||||
pagination={<UserPagination />}
|
||||
>
|
||||
<Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}>
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sx={{ height: "40px", width: "40px" }}
|
||||
sortBy="avatar_url"
|
||||
/>
|
||||
<TextField source="id" sortBy="name" />
|
||||
<TextField source="displayname" />
|
||||
<BooleanField source="is_guest" />
|
||||
<BooleanField source="admin" />
|
||||
<BooleanField source="deactivated" />
|
||||
<DateField
|
||||
source="creation_ts"
|
||||
label="resources.users.fields.creation_ts_ms"
|
||||
showTime
|
||||
options={date_format}
|
||||
/>
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
// https://matrix.org/docs/spec/appendices#user-identifiers
|
||||
// here only local part of user_id
|
||||
// maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length
|
||||
// localStorage.getItem("home_server").length is not valid here
|
||||
const validateUser = [
|
||||
required(),
|
||||
maxLength(253),
|
||||
regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id"),
|
||||
];
|
||||
|
||||
const validateAddress = [required(), maxLength(255)];
|
||||
|
||||
export function generateRandomUser() {
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
const user_id =
|
||||
"@" +
|
||||
Array(8)
|
||||
.fill("0123456789abcdefghijklmnopqrstuvwxyz")
|
||||
.map(
|
||||
x =>
|
||||
x[
|
||||
Math.floor(
|
||||
(crypto.getRandomValues(new Uint32Array(1))[0] /
|
||||
(0xffffffff + 1)) *
|
||||
x.length
|
||||
)
|
||||
]
|
||||
)
|
||||
.join("") +
|
||||
":" +
|
||||
homeserver;
|
||||
|
||||
const password = Array(20)
|
||||
.fill(
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$"
|
||||
)
|
||||
.map(
|
||||
x =>
|
||||
x[
|
||||
Math.floor(
|
||||
(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1)) *
|
||||
x.length
|
||||
)
|
||||
]
|
||||
)
|
||||
.join("");
|
||||
|
||||
return {
|
||||
id: user_id,
|
||||
password: password,
|
||||
};
|
||||
}
|
||||
|
||||
const UserEditToolbar = props => (
|
||||
<Toolbar {...props}>
|
||||
<SaveButton disabled={props.pristine} />
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
const UserEditActions = ({ data }) => {
|
||||
const translate = useTranslate();
|
||||
var userStatus = "";
|
||||
if (data) {
|
||||
userStatus = data.deactivated;
|
||||
}
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
{!userStatus && <ServerNoticeButton record={data} />}
|
||||
<DeleteButton
|
||||
record={data}
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle={translate("resources.users.helper.erase", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserCreate = props => (
|
||||
<Create {...props}>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||
<TextInput source="displayname" validate={maxLength(256)} />
|
||||
<PasswordInput
|
||||
source="password"
|
||||
autoComplete="new-password"
|
||||
validate={maxLength(512)}
|
||||
/>
|
||||
<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>
|
||||
</Create>
|
||||
);
|
||||
|
||||
const UserTitle = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<span>
|
||||
{translate("resources.users.name", {
|
||||
smart_count: 1,
|
||||
})}{" "}
|
||||
{record ? `"${record.displayname}"` : ""}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserEdit = props => {
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||
<FormTab
|
||||
label={translate("resources.users.name", { smart_count: 1 })}
|
||||
icon={<PersonPinIcon />}
|
||||
>
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sortable={false}
|
||||
sx={{ height: "120px", width: "120px", float: "right" }}
|
||||
/>
|
||||
<TextInput source="id" disabled />
|
||||
<TextInput source="displayname" />
|
||||
<PasswordInput
|
||||
source="password"
|
||||
autoComplete="new-password"
|
||||
helperText="resources.users.helper.password"
|
||||
/>
|
||||
<SelectInput
|
||||
source="user_type"
|
||||
choices={choices_type}
|
||||
translateChoice={false}
|
||||
resettable
|
||||
/>
|
||||
<BooleanInput source="admin" />
|
||||
<BooleanInput
|
||||
source="deactivated"
|
||||
helperText="resources.users.helper.deactivate"
|
||||
/>
|
||||
<DateField source="creation_ts_ms" showTime options={date_format} />
|
||||
<TextField source="consent_version" />
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label="resources.users.threepid"
|
||||
icon={<ContactMailIcon />}
|
||||
path="threepid"
|
||||
>
|
||||
<ArrayInput source="threepids">
|
||||
<SimpleFormIterator disableReordering>
|
||||
<SelectInput source="medium" choices={choices_medium} />
|
||||
<TextInput source="address" />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label="synapseadmin.users.tabs.sso"
|
||||
icon={<AssignmentIndIcon />}
|
||||
path="sso"
|
||||
>
|
||||
<ArrayInput source="external_ids" label={false}>
|
||||
<SimpleFormIterator disableReordering>
|
||||
<TextInput source="auth_provider" validate={required()} />
|
||||
<TextInput
|
||||
source="external_id"
|
||||
label="resources.users.fields.id"
|
||||
validate={required()}
|
||||
/>
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label={translate("resources.devices.name", { smart_count: 2 })}
|
||||
icon={<DevicesIcon />}
|
||||
path="devices"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="devices"
|
||||
target="user_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }}>
|
||||
<TextField source="device_id" sortable={false} />
|
||||
<TextField source="display_name" sortable={false} />
|
||||
<TextField source="last_seen_ip" sortable={false} />
|
||||
<DateField
|
||||
source="last_seen_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={false}
|
||||
/>
|
||||
<DeviceRemoveButton />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label="resources.connections.name"
|
||||
icon={<SettingsInputComponentIcon />}
|
||||
path="connections"
|
||||
>
|
||||
<ReferenceField
|
||||
reference="connections"
|
||||
source="id"
|
||||
addLabel={false}
|
||||
link={false}
|
||||
>
|
||||
<ArrayField
|
||||
source="devices[].sessions[0].connections"
|
||||
label="resources.connections.name"
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="ip" sortable={false} />
|
||||
<DateField
|
||||
source="last_seen"
|
||||
showTime
|
||||
options={date_format}
|
||||
sortable={false}
|
||||
/>
|
||||
<TextField
|
||||
source="user_agent"
|
||||
sortable={false}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Datagrid>
|
||||
</ArrayField>
|
||||
</ReferenceField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label={translate("resources.users_media.name", { smart_count: 2 })}
|
||||
icon={<PermMediaIcon />}
|
||||
path="media"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="users_media"
|
||||
target="user_id"
|
||||
addLabel={false}
|
||||
pagination={<UserPagination />}
|
||||
perPage={50}
|
||||
sort={{ field: "created_ts", order: "DESC" }}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }}>
|
||||
<DateField source="created_ts" showTime options={date_format} />
|
||||
<DateField
|
||||
source="last_access_ts"
|
||||
showTime
|
||||
options={date_format}
|
||||
/>
|
||||
<TextField source="media_id" />
|
||||
<NumberField source="media_length" />
|
||||
<TextField source="media_type" />
|
||||
<TextField source="upload_name" />
|
||||
<TextField source="quarantined_by" />
|
||||
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
||||
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
||||
<DeleteButton mutationMode="pessimistic" redirect={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label={translate("resources.rooms.name", { smart_count: 2 })}
|
||||
icon={<ViewListIcon />}
|
||||
path="rooms"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="joined_rooms"
|
||||
target="user_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid
|
||||
style={{ width: "100%" }}
|
||||
rowClick={(id, resource, record) => "/rooms/" + id + "/show"}
|
||||
bulkActionButtons={false}
|
||||
>
|
||||
<TextField
|
||||
source="id"
|
||||
sortable={false}
|
||||
label="resources.rooms.fields.room_id"
|
||||
/>
|
||||
<ReferenceField
|
||||
label="resources.rooms.fields.name"
|
||||
source="id"
|
||||
reference="rooms"
|
||||
sortable={false}
|
||||
link=""
|
||||
>
|
||||
<TextField source="name" sortable={false} />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab
|
||||
label={translate("resources.pushers.name", { smart_count: 2 })}
|
||||
icon={<NotificationsIcon />}
|
||||
path="pushers"
|
||||
>
|
||||
<ReferenceManyField
|
||||
reference="pushers"
|
||||
target="user_id"
|
||||
addLabel={false}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="kind" sortable={false} />
|
||||
<TextField source="app_display_name" sortable={false} />
|
||||
<TextField source="app_id" sortable={false} />
|
||||
<TextField source="data.url" sortable={false} />
|
||||
<TextField source="device_display_name" sortable={false} />
|
||||
<TextField source="lang" sortable={false} />
|
||||
<TextField source="profile_tag" sortable={false} />
|
||||
<TextField source="pushkey" sortable={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
</TabbedForm>
|
||||
</Edit>
|
||||
);
|
||||
};
|
||||
|
||||
const resource = {
|
||||
name: "users",
|
||||
icon: UserIcon,
|
||||
list: UserList,
|
||||
edit: UserEdit,
|
||||
create: UserCreate,
|
||||
};
|
||||
|
||||
export default resource;
|
379
src/i18n/de.js
379
src/i18n/de.js
@ -1,29 +1,113 @@
|
||||
import germanMessages from "ra-language-german";
|
||||
|
||||
export default {
|
||||
const de = {
|
||||
...germanMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
homeserver: "Heimserver",
|
||||
base_url: "Heimserver URL",
|
||||
welcome: "Willkommen bei Synapse-admin",
|
||||
server_version: "Synapse Version",
|
||||
supports_specs: "unterstützt Matrix-Specs",
|
||||
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
|
||||
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
||||
url_error: "Keine gültige Matrix Server URL",
|
||||
sso_sign_in: "Anmeldung mit SSO",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id:
|
||||
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
|
||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Raumdetails",
|
||||
tabs: {
|
||||
basic: "Allgemein",
|
||||
members: "Mitglieder",
|
||||
detail: "Details",
|
||||
permission: "Berechtigungen",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "Bei Eintrag %{entry}: %{message}",
|
||||
error: "Fehler",
|
||||
required_field: "Pflichtfeld '%{field}' fehlt",
|
||||
invalid_value:
|
||||
"Ungültiger Wert in Zeile %{row}. Feld '%{field}' darf nur die Werte 'true' oder 'false' enthalten",
|
||||
unreasonably_big: "Datei ist zu groß für den Import (%{size} Megabytes)",
|
||||
already_in_progress: "Es läuft bereits ein Import",
|
||||
id_exits: "ID %{id} existiert bereits",
|
||||
},
|
||||
title: "Benutzer aus CSV importieren",
|
||||
goToPdf: "Gehe zum PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Benutzer importieren",
|
||||
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",
|
||||
},
|
||||
conflicts: {
|
||||
header: "Konfliktstrategie",
|
||||
mode: {
|
||||
stop: "Stoppe bei Fehlern",
|
||||
skip: "Zeige Fehler und überspringe fehlerhafte Einträge",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "IDs",
|
||||
all_ids_present: "IDs in jedem Eintrag vorhanden",
|
||||
count_ids_present:
|
||||
"%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
|
||||
mode: {
|
||||
ignore: "Ignoriere IDs der CSV-Datei und erstelle neue",
|
||||
update: "Aktualisiere existierende Benutzer",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "Passwörter",
|
||||
all_passwords_present: "Passwörter in jedem Eintrag vorhanden",
|
||||
count_passwords_present:
|
||||
"%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
|
||||
use_passwords: "Verwende Passwörter aus der CSV Datei",
|
||||
},
|
||||
upload: {
|
||||
header: "CSV Datei importieren",
|
||||
explanation:
|
||||
"Hier können Sie eine Datei mit kommagetrennten Daten hochladen, die verwendet werden um Benutzer anzulegen oder zu ändern. Die Datei muss mindestens die Felder 'id' und 'displayname' enthalten. Hier können Sie eine Beispieldatei herunterladen und anpassen: ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Nur simulieren",
|
||||
run_import: "Importieren",
|
||||
},
|
||||
results: {
|
||||
header: "Ergebnis",
|
||||
total:
|
||||
"%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
|
||||
successful: "%{smart_count} Einträge erfolgreich importiert",
|
||||
skipped: "%{smart_count} Einträge übersprungen",
|
||||
download_skipped: "Übersprungene Einträge herunterladen",
|
||||
with_error:
|
||||
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
|
||||
simulated_only: "Import-Vorgang war nur simuliert",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
backtolist: "Zurück zur Liste",
|
||||
name: "Benutzer",
|
||||
email: "E-Mail",
|
||||
msisdn: "Telefon",
|
||||
threepid: "E-Mail / Telefon",
|
||||
fields: {
|
||||
avatar: "Avatar",
|
||||
id: "Benutzer-ID",
|
||||
name: "Name",
|
||||
is_guest: "Gast",
|
||||
admin: "Admin",
|
||||
admin: "Server Administrator",
|
||||
deactivated: "Deaktiviert",
|
||||
guests: "Zeige Gäste",
|
||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||
@ -31,12 +115,24 @@ export default {
|
||||
displayname: "Anzeigename",
|
||||
password: "Passwort",
|
||||
avatar_url: "Avatar URL",
|
||||
avatar_src: "Avatar",
|
||||
medium: "Medium",
|
||||
threepids: "3PIDs",
|
||||
address: "Adresse",
|
||||
creation_ts_ms: "Zeitpunkt der Erstellung",
|
||||
consent_version: "Zugestimmte Geschäftsbedingungen",
|
||||
auth_provider: "Provider",
|
||||
user_type: "Benutzertyp",
|
||||
},
|
||||
helper: {
|
||||
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
|
||||
password:
|
||||
"Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||
deactivate:
|
||||
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
||||
},
|
||||
action: {
|
||||
erase: "Lösche Benutzerdaten",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@ -46,6 +142,81 @@ export default {
|
||||
name: "Name",
|
||||
canonical_alias: "Alias",
|
||||
joined_members: "Mitglieder",
|
||||
joined_local_members: "Lokale Mitglieder",
|
||||
joined_local_devices: "Lokale Endgeräte",
|
||||
state_events: "Zustandsereignisse / Komplexität",
|
||||
version: "Version",
|
||||
is_encrypted: "Verschlüsselt",
|
||||
encryption: "Verschlüsselungs-Algorithmus",
|
||||
federatable: "Föderierbar",
|
||||
public: "Sichtbar im Raumverzeichnis",
|
||||
creator: "Ersteller",
|
||||
join_rules: "Beitrittsregeln",
|
||||
guest_access: "Gastzugriff",
|
||||
history_visibility: "Historie-Sichtbarkeit",
|
||||
topic: "Thema",
|
||||
avatar: "Avatar",
|
||||
},
|
||||
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: {
|
||||
join_rules: {
|
||||
public: "Öffentlich",
|
||||
knock: "Auf Anfrage",
|
||||
invite: "Nur auf Einladung",
|
||||
private: "Privat",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Gäste können beitreten",
|
||||
forbidden: "Gäste können nicht beitreten",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "Ab Einladung",
|
||||
joined: "Ab Beitritt",
|
||||
shared: "Ab Setzen der Einstellung",
|
||||
world_readable: "Jeder",
|
||||
},
|
||||
unencrypted: "Nicht verschlüsselt",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Raum löschen",
|
||||
content:
|
||||
"Sind Sie sicher dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "Meldezeit",
|
||||
user_id: "Meldender",
|
||||
name: "Raumname",
|
||||
score: "Wert",
|
||||
reason: "Grund",
|
||||
event_id: "Event-ID",
|
||||
event_json: {
|
||||
origin: "Ursprungsserver",
|
||||
origin_server_ts: "Sendezeit",
|
||||
type: "Eventtyp",
|
||||
content: {
|
||||
msgtype: "Inhaltstyp",
|
||||
body: "Nachrichteninhalt",
|
||||
format: "Nachrichtenformat",
|
||||
formatted_body: "Formatierter Nachrichteninhalt",
|
||||
algorithm: "Verschlüsselungsalgorithmus",
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Gemeldetes Event löschen",
|
||||
content:
|
||||
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
@ -56,5 +227,199 @@ export default {
|
||||
user_agent: "User Agent",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "Gerät |||| Geräte",
|
||||
fields: {
|
||||
device_id: "Geräte-ID",
|
||||
display_name: "Gerätename",
|
||||
last_seen_ts: "Zeitstempel",
|
||||
last_seen_ip: "IP-Adresse",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Entferne %{id}",
|
||||
content: 'Möchten Sie das Gerät "%{name}" wirklich entfernen?',
|
||||
success: "Gerät erfolgreich entfernt.",
|
||||
failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "Medien",
|
||||
fields: {
|
||||
media_id: "Medien ID",
|
||||
media_length: "Größe",
|
||||
media_type: "Typ",
|
||||
upload_name: "Dateiname",
|
||||
quarantined_by: "Zur Quarantäne hinzugefügt",
|
||||
safe_from_quarantine: "Schutz vor Quarantäne",
|
||||
created_ts: "Erstellt",
|
||||
last_access_ts: "Letzter Zugriff",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "Medien",
|
||||
fields: {
|
||||
before_ts: "Letzter Zugriff vor",
|
||||
size_gt: "Größer als (in Bytes)",
|
||||
keep_profiles: "Behalte Profilbilder",
|
||||
},
|
||||
action: {
|
||||
send: "Medien löschen",
|
||||
send_success: "Anfrage erfolgreich versendet.",
|
||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||
},
|
||||
helper: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Ungeschützt, Schutz erstellen",
|
||||
delete: "Geschützt, Schutz aufheben",
|
||||
none: "In Quarantäne",
|
||||
send_success: "Erfolgreich den Schutz-Status geändert.",
|
||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Quarantäne",
|
||||
create: "Zur Quarantäne hinzufügen",
|
||||
delete: "In Quarantäne, Quarantäne aufheben",
|
||||
none: "Geschützt vor Quarantäne",
|
||||
send_success: "Erfolgreich den Quarantäne-Status geändert.",
|
||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "Pusher |||| Pushers",
|
||||
fields: {
|
||||
app: "App",
|
||||
app_display_name: "App-Anzeigename",
|
||||
app_id: "App ID",
|
||||
device_display_name: "Geräte-Anzeigename",
|
||||
kind: "Art",
|
||||
lang: "Sprache",
|
||||
profile_tag: "Profil-Tag",
|
||||
pushkey: "Pushkey",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "Serverbenachrichtigungen",
|
||||
send: "Servernachricht versenden",
|
||||
fields: {
|
||||
body: "Nachricht",
|
||||
},
|
||||
action: {
|
||||
send: "Sende Nachricht",
|
||||
send_success: "Nachricht erfolgreich versendet.",
|
||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||
},
|
||||
helper: {
|
||||
send: 'Sendet eine Serverbenachrichtigung an die ausgewählten Nutzer. Hierfür muss das Feature "Server Notices" auf dem Server aktiviert sein.',
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "Dateien je Benutzer",
|
||||
fields: {
|
||||
media_count: "Anzahl der Dateien",
|
||||
media_length: "Größe der Dateien",
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Vorderextremitäten",
|
||||
fields: {
|
||||
id: "Event-ID",
|
||||
received_ts: "Zeitstempel",
|
||||
depth: "Tiefe",
|
||||
state_group: "Zustandsgruppe",
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "Zustandsereignisse",
|
||||
fields: {
|
||||
type: "Typ",
|
||||
content: "Inhalt",
|
||||
origin_server_ts: "Sendezeit",
|
||||
sender: "Absender",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Raumverzeichnis",
|
||||
fields: {
|
||||
world_readable: "Gastbenutzer dürfen ohne Beitritt lesen",
|
||||
guest_can_join: "Gastbenutzer dürfen beitreten",
|
||||
},
|
||||
action: {
|
||||
title:
|
||||
"Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
|
||||
content:
|
||||
"Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?",
|
||||
erase: "Lösche aus Verzeichnis",
|
||||
create: "Eintragen ins Verzeichnis",
|
||||
send_success: "Raum erfolgreich eingetragen.",
|
||||
send_failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
||||
},
|
||||
},
|
||||
destinations: {
|
||||
name: "Föderation",
|
||||
fields: {
|
||||
destination: "Ziel",
|
||||
failure_ts: "Fehlerzeitpunkt",
|
||||
retry_last_ts: "Letzter Wiederholungsversuch",
|
||||
retry_interval: "Wiederholungsintervall",
|
||||
last_successful_stream_ordering: "letzte erfogreicher Stream",
|
||||
stream_ordering: "Stream",
|
||||
},
|
||||
action: { reconnect: "Neu verbinden" },
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "Registrierungstoken",
|
||||
fields: {
|
||||
token: "Token",
|
||||
valid: "Gültige Token",
|
||||
uses_allowed: "Anzahl",
|
||||
pending: "Ausstehend",
|
||||
completed: "Abgeschlossen",
|
||||
expiry_time: "Ablaufzeit",
|
||||
length: "Länge",
|
||||
},
|
||||
helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." },
|
||||
},
|
||||
},
|
||||
ra: {
|
||||
...germanMessages.ra,
|
||||
action: {
|
||||
...germanMessages.ra.action,
|
||||
unselect: "Abwählen",
|
||||
},
|
||||
auth: {
|
||||
...germanMessages.ra.auth,
|
||||
auth_check_error: "Anmeldung fehlgeschlagen",
|
||||
},
|
||||
input: {
|
||||
...germanMessages.ra.input,
|
||||
password: {
|
||||
...germanMessages.ra.input.password,
|
||||
toggle_hidden: "Anzeigen",
|
||||
toggle_visible: "Verstecken",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
...germanMessages.ra.notification,
|
||||
logged_out: "Abgemeldet",
|
||||
},
|
||||
page: {
|
||||
...germanMessages.ra.page,
|
||||
empty: "Keine Einträge vorhanden",
|
||||
invite: "",
|
||||
},
|
||||
navigation: {
|
||||
...germanMessages.ra.navigation,
|
||||
skip_nav: "Zum Inhalt springen",
|
||||
},
|
||||
},
|
||||
};
|
||||
export default de;
|
||||
|
344
src/i18n/en.js
344
src/i18n/en.js
@ -1,29 +1,112 @@
|
||||
import englishMessages from "ra-language-english";
|
||||
|
||||
export default {
|
||||
const en = {
|
||||
...englishMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
homeserver: "Homeserver",
|
||||
base_url: "Homeserver URL",
|
||||
welcome: "Welcome to Synapse-admin",
|
||||
server_version: "Synapse version",
|
||||
supports_specs: "supports Matrix specs",
|
||||
username_error: "Please enter fully qualified user ID: '@user:domain'",
|
||||
protocol_error: "URL has to start with 'http://' or 'https://'",
|
||||
url_error: "Not a valid Matrix server URL",
|
||||
sso_sign_in: "Sign in with SSO",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id:
|
||||
"Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver",
|
||||
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "Basic",
|
||||
members: "Members",
|
||||
detail: "Details",
|
||||
permission: "Permissions",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Basic", detail: "Details" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "At entry %{entry}: %{message}",
|
||||
error: "Error",
|
||||
required_field: "Required field '%{field}' is not present",
|
||||
invalid_value:
|
||||
"Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
|
||||
unreasonably_big:
|
||||
"Refused to load unreasonably big file of %{size} megabytes",
|
||||
already_in_progress: "An import run is already in progress",
|
||||
id_exits: "ID %{id} already present",
|
||||
},
|
||||
title: "Import users via CSV",
|
||||
goToPdf: "Go to PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Import users",
|
||||
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",
|
||||
},
|
||||
conflicts: {
|
||||
header: "Conflict strategy",
|
||||
mode: {
|
||||
stop: "Stop on conflict",
|
||||
skip: "Show error and skip on conflict",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "IDs",
|
||||
all_ids_present: "IDs present on every entry",
|
||||
count_ids_present:
|
||||
"%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
|
||||
mode: {
|
||||
ignore: "Ignore IDs in CSV and create new ones",
|
||||
update: "Update existing records",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "Passwords",
|
||||
all_passwords_present: "Passwords present on every entry",
|
||||
count_passwords_present:
|
||||
"%{smart_count} entry with password |||| %{smart_count} entries with passwords",
|
||||
use_passwords: "Use passwords from CSV",
|
||||
},
|
||||
upload: {
|
||||
header: "Input CSV file",
|
||||
explanation:
|
||||
"Here you can upload a file with comma separated values that is processed to create or update users. The file must include the fields 'id' and 'displayname'. You can download and adapt an example file here: ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Simulate only",
|
||||
run_import: "Import",
|
||||
},
|
||||
results: {
|
||||
header: "Import results",
|
||||
total:
|
||||
"%{smart_count} entry in total |||| %{smart_count} entries in total",
|
||||
successful: "%{smart_count} entries successfully imported",
|
||||
skipped: "%{smart_count} entries skipped",
|
||||
download_skipped: "Download skipped records",
|
||||
with_error:
|
||||
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
|
||||
simulated_only: "Run was only simulated",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
backtolist: "Back to list",
|
||||
name: "User |||| Users",
|
||||
email: "Email",
|
||||
msisdn: "Phone",
|
||||
threepid: "Email / Phone",
|
||||
fields: {
|
||||
avatar: "Avatar",
|
||||
id: "User-ID",
|
||||
name: "Name",
|
||||
is_guest: "Guest",
|
||||
admin: "Admin",
|
||||
admin: "Server Administrator",
|
||||
deactivated: "Deactivated",
|
||||
guests: "Show guests",
|
||||
show_deactivated: "Show deactivated users",
|
||||
@ -31,12 +114,22 @@ export default {
|
||||
displayname: "Displayname",
|
||||
password: "Password",
|
||||
avatar_url: "Avatar URL",
|
||||
avatar_src: "Avatar",
|
||||
medium: "Medium",
|
||||
threepids: "3PIDs",
|
||||
address: "Address",
|
||||
creation_ts_ms: "Creation timestamp",
|
||||
consent_version: "Consent version",
|
||||
auth_provider: "Provider",
|
||||
user_type: "User type",
|
||||
},
|
||||
helper: {
|
||||
deactivate: "Deactivated users cannot be reactivated",
|
||||
password: "Changing password will log user out of all sessions.",
|
||||
deactivate: "You must provide a password to re-activate an account.",
|
||||
erase: "Mark the user as GDPR-erased",
|
||||
},
|
||||
action: {
|
||||
erase: "Erase user data",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@ -46,6 +139,81 @@ export default {
|
||||
name: "Name",
|
||||
canonical_alias: "Alias",
|
||||
joined_members: "Members",
|
||||
joined_local_members: "Local members",
|
||||
joined_local_devices: "Local devices",
|
||||
state_events: "State events / Complexity",
|
||||
version: "Version",
|
||||
is_encrypted: "Encrypted",
|
||||
encryption: "Encryption",
|
||||
federatable: "Federatable",
|
||||
public: "Visible in room directory",
|
||||
creator: "Creator",
|
||||
join_rules: "Join rules",
|
||||
guest_access: "Guest access",
|
||||
history_visibility: "History visibility",
|
||||
topic: "Topic",
|
||||
avatar: "Avatar",
|
||||
},
|
||||
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: {
|
||||
join_rules: {
|
||||
public: "Public",
|
||||
knock: "Knock",
|
||||
invite: "Invite",
|
||||
private: "Private",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Guests can join",
|
||||
forbidden: "Guests can not join",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "Since invited",
|
||||
joined: "Since joined",
|
||||
shared: "Since shared",
|
||||
world_readable: "Anyone",
|
||||
},
|
||||
unencrypted: "Unencrypted",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Delete room",
|
||||
content:
|
||||
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Reported event |||| Reported events",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "report time",
|
||||
user_id: "announcer",
|
||||
name: "name of the room",
|
||||
score: "score",
|
||||
reason: "reason",
|
||||
event_id: "event ID",
|
||||
event_json: {
|
||||
origin: "origin server",
|
||||
origin_server_ts: "time of send",
|
||||
type: "event type",
|
||||
content: {
|
||||
msgtype: "content type",
|
||||
body: "content",
|
||||
format: "format",
|
||||
formatted_body: "formatted content",
|
||||
algorithm: "algorithm",
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Delete reported event",
|
||||
content:
|
||||
"Are you sure you want to delete the reported event? This cannot be undone.",
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
@ -56,5 +224,167 @@ export default {
|
||||
user_agent: "User agent",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "Device |||| Devices",
|
||||
fields: {
|
||||
device_id: "Device-ID",
|
||||
display_name: "Device name",
|
||||
last_seen_ts: "Timestamp",
|
||||
last_seen_ip: "IP address",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Removing %{id}",
|
||||
content: 'Are you sure you want to remove the device "%{name}"?',
|
||||
success: "Device successfully removed.",
|
||||
failure: "An error has occurred.",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "Media ID",
|
||||
media_length: "File Size (in Bytes)",
|
||||
media_type: "Type",
|
||||
upload_name: "File name",
|
||||
quarantined_by: "Quarantined by",
|
||||
safe_from_quarantine: "Safe from quarantine",
|
||||
created_ts: "Created",
|
||||
last_access_ts: "Last access",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
before_ts: "last access before",
|
||||
size_gt: "Larger then (in bytes)",
|
||||
keep_profiles: "Keep profile images",
|
||||
},
|
||||
action: {
|
||||
send: "Delete media",
|
||||
send_success: "Request successfully sent.",
|
||||
send_failure: "An error has occurred.",
|
||||
},
|
||||
helper: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Unprotected, create protection",
|
||||
delete: "Protected, remove protection",
|
||||
none: "In quarantine",
|
||||
send_success: "Successfully changed the protection status.",
|
||||
send_failure: "An error has occurred.",
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Quarantine",
|
||||
create: "Add to quarantine",
|
||||
delete: "In quarantine, unquarantine",
|
||||
none: "Protected from quarantine",
|
||||
send_success: "Successfully changed the quarantine status.",
|
||||
send_failure: "An error has occurred.",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "Pusher |||| Pushers",
|
||||
fields: {
|
||||
app: "App",
|
||||
app_display_name: "App display name",
|
||||
app_id: "App ID",
|
||||
device_display_name: "Device display name",
|
||||
kind: "Kind",
|
||||
lang: "Language",
|
||||
profile_tag: "Profile tag",
|
||||
pushkey: "Pushkey",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "Server Notices",
|
||||
send: "Send server notices",
|
||||
fields: {
|
||||
body: "Message",
|
||||
},
|
||||
action: {
|
||||
send: "Send note",
|
||||
send_success: "Server notice successfully sent.",
|
||||
send_failure: "An error has occurred.",
|
||||
},
|
||||
helper: {
|
||||
send: 'Sends a server notice to the selected users. The feature "Server Notices" has to be activated at the server.',
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "Users' media",
|
||||
fields: {
|
||||
media_count: "Media count",
|
||||
media_length: "Media length",
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Forward Extremities",
|
||||
fields: {
|
||||
id: "Event ID",
|
||||
received_ts: "Timestamp",
|
||||
depth: "Depth",
|
||||
state_group: "State group",
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "State events",
|
||||
fields: {
|
||||
type: "Type",
|
||||
content: "Content",
|
||||
origin_server_ts: "time of send",
|
||||
sender: "Sender",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Room directory",
|
||||
fields: {
|
||||
world_readable: "guest users may view without joining",
|
||||
guest_can_join: "guest users may join",
|
||||
},
|
||||
action: {
|
||||
title:
|
||||
"Delete room from directory |||| Delete %{smart_count} rooms from directory",
|
||||
content:
|
||||
"Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory?",
|
||||
erase: "Delete from room directory",
|
||||
create: "Publish in room directory",
|
||||
send_success: "Room successfully published.",
|
||||
send_failure: "An error has occurred.",
|
||||
},
|
||||
},
|
||||
destinations: {
|
||||
name: "Federation",
|
||||
fields: {
|
||||
destination: "Destination",
|
||||
failure_ts: "Failure timestamp",
|
||||
retry_last_ts: "Last retry timestamp",
|
||||
retry_interval: "Retry interval",
|
||||
last_successful_stream_ordering: "Last successful stream",
|
||||
stream_ordering: "Stream",
|
||||
},
|
||||
action: { reconnect: "Reconnect" },
|
||||
},
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "Registration tokens",
|
||||
fields: {
|
||||
token: "Token",
|
||||
valid: "Valid token",
|
||||
uses_allowed: "Uses allowed",
|
||||
pending: "Pending",
|
||||
completed: "Completed",
|
||||
expiry_time: "Expiry time",
|
||||
length: "Length",
|
||||
},
|
||||
helper: { length: "Length of the token if no token is given." },
|
||||
},
|
||||
};
|
||||
export default en;
|
||||
|
382
src/i18n/fa.js
Normal file
382
src/i18n/fa.js
Normal file
@ -0,0 +1,382 @@
|
||||
import farsiMessages from "ra-language-farsi";
|
||||
|
||||
const fa = {
|
||||
...farsiMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "آدرس سرور",
|
||||
welcome: "به پنل مدیریت سیناپس خوش آمدید!",
|
||||
server_version: "نسخه",
|
||||
username_error: "لطفاً شناسه کاربر را وارد کنید: '@user:domain'",
|
||||
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
||||
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
||||
sso_sign_in: "با SSO وارد شوید",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "اصلی",
|
||||
members: "اعضا",
|
||||
detail: "جزئیات",
|
||||
permission: "مجوزها",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "در هنگام ورود %{entry}: %{message}",
|
||||
error: "Error",
|
||||
required_field: "فیلد الزامی '%{field}' وجود ندارد",
|
||||
invalid_value:
|
||||
"خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد",
|
||||
unreasonably_big:
|
||||
"از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
|
||||
already_in_progress: "یک بارگذاری از قبل در حال انجام است",
|
||||
id_exits: "شناسه %{id} موجود است",
|
||||
},
|
||||
title: "کاربران را از طریق فایل CSV وارد کنید",
|
||||
goToPdf: "رفتن به PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
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",
|
||||
},
|
||||
conflicts: {
|
||||
header: "استراتژی متغارض",
|
||||
mode: {
|
||||
stop: "توقف",
|
||||
skip: "نمایش خطا و رد شدن",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "شناسنامه ها",
|
||||
all_ids_present: "شناسه های موجود در هر ورودی",
|
||||
count_ids_present:
|
||||
"%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
|
||||
mode: {
|
||||
ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن",
|
||||
update: "سوابق موجود را به روز کنید",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "رمز عبور",
|
||||
all_passwords_present: "رمزهای عبور موجود در هر ورودی",
|
||||
count_passwords_present:
|
||||
"%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
|
||||
use_passwords: "از پسوردهای CSV استفاده کنید",
|
||||
},
|
||||
upload: {
|
||||
header: "Input CSV file",
|
||||
explanation:
|
||||
"در اینجا می توانید فایلی را با مقادیر جدا شده با کاما بارگذاری کنید که برای ایجاد یا به روز رسانی کاربران پردازش می شود. فایل باید شامل فیلدهای 'id' و 'displayname' باشد. می توانید یک فایل نمونه را از اینجا دانلود و تطبیق دهید: ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "فقط شبیه سازی",
|
||||
run_import: "بارگذاری",
|
||||
},
|
||||
results: {
|
||||
header: "بارگذاری نتایج",
|
||||
total: "%{smart_count} ورودی در کل |||| %{smart_count} ورودی ها در کل",
|
||||
successful: "%{smart_count} ورودی ها با موفقیت وارد شدند",
|
||||
skipped: "%{smart_count} ورودی ها نادیده گرفته شدند",
|
||||
download_skipped: "دانلود رکوردهای نادیده گرفته شده",
|
||||
with_error:
|
||||
"%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
|
||||
simulated_only: "اجرا فقط شبیه سازی شد",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "کاربر |||| کاربران",
|
||||
email: "ایمیل",
|
||||
msisdn: "شماره تلفن",
|
||||
threepid: "ایمیل / شماره تلفن",
|
||||
fields: {
|
||||
avatar: "آواتار",
|
||||
id: "شناسه کاربر",
|
||||
name: "نام",
|
||||
is_guest: "مهمان",
|
||||
admin: "مدیر سرور",
|
||||
deactivated: "غیرفعال",
|
||||
guests: "نمایش مهمانان",
|
||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||
user_id: "جستجوی کاربر",
|
||||
displayname: "نام نمایشی",
|
||||
password: "رمز عبور",
|
||||
avatar_url: "آواتار سرور",
|
||||
avatar_src: "آواتار",
|
||||
medium: "متوسط",
|
||||
threepids: "سرویس احراز هویت",
|
||||
address: "آدرس",
|
||||
creation_ts_ms: "ساخته شده در",
|
||||
consent_version: "Consent نسخه",
|
||||
auth_provider: "ارائه دهنده",
|
||||
user_type: "نوع کاربر",
|
||||
},
|
||||
helper: {
|
||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||
},
|
||||
action: {
|
||||
erase: "پاک کردن اطلاعات کاربر",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "اتاق |||| اتاق ها",
|
||||
fields: {
|
||||
room_id: "شناسه اتاق",
|
||||
name: "نام",
|
||||
canonical_alias: "نام مستعار",
|
||||
joined_members: "اعضا",
|
||||
joined_local_members: "اعضای محلی",
|
||||
joined_local_devices: "دستگاه های محلی",
|
||||
state_events: "رویدادهای حالت / پیچیدگی",
|
||||
version: "نسخه",
|
||||
is_encrypted: "رمزگذاری شده است",
|
||||
encryption: "رمزگذاری",
|
||||
federatable: "Federatable",
|
||||
public: "قابل مشاهده در فهرست اتاق",
|
||||
creator: "سازنده",
|
||||
join_rules: "به قوانین بپیوندید",
|
||||
guest_access: "دسترسی مهمان",
|
||||
history_visibility: "مشاهده تاریخچه",
|
||||
topic: "موضوع",
|
||||
avatar: "آواتار",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
"اندام های رو به جلو، رویدادهای برگ در انتهای نمودار غیر چرخه ای جهت دار (DAG) در یک اتاق هستند، رویدادهایی که فرزند ندارند. هر چه تعداد بیشتری در یک اتاق وجود داشته باشد، وضوح حالت بیشتری را که سیناپس باید انجام دهد (نکته: این یک عملیات گران است). در حالی که Synapse کدی برای جلوگیری از وجود تعداد زیادی از این موارد در یک زمان در اتاق دارد، گاهی اوقات باگها میتوانند دوباره ظاهر شوند. اگر اتاقی بیش از 10 انتهای رو به جلو دارد، بهتر است بررسی کنید که کدام اتاق مقصر است و احتمالاً آنها را با استفاده از جستارهای SQL ذکر شده در آن حذف کنید. #1760.",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
public: "عمومی",
|
||||
knock: "در زدن",
|
||||
invite: "دعوت کردن",
|
||||
private: "خصوصی",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "مهمانان می توانند ملحق شوند",
|
||||
forbidden: "مهمانان نمی توانند ملحق شوند",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "از آنجایی که دعوت شده است",
|
||||
joined: "از زمانی که پیوست",
|
||||
shared: "از آنجایی که به اشتراک گذاشته شده است",
|
||||
world_readable: "هر کسی",
|
||||
},
|
||||
unencrypted: "رمزگذاری نشده",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "حذف اتاق",
|
||||
content:
|
||||
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "رویداد گزارش شده |||| رویدادهای گزارش شده",
|
||||
fields: {
|
||||
id: "شناسه",
|
||||
received_ts: "زمان گزارش",
|
||||
user_id: "گوینده",
|
||||
name: "نام اتاق",
|
||||
score: "نمره",
|
||||
reason: "دلیل",
|
||||
event_id: "شناسه رویداد",
|
||||
event_json: {
|
||||
origin: "سرور مبدا",
|
||||
origin_server_ts: "زمان ارسال",
|
||||
type: "نوع رویداد",
|
||||
content: {
|
||||
msgtype: "نوع محتوا",
|
||||
body: "محتوا",
|
||||
format: "قالب",
|
||||
formatted_body: "محتوای قالب بندی شده",
|
||||
algorithm: "الگوریتم",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
name: "اتصالات",
|
||||
fields: {
|
||||
last_seen: "تاریخ",
|
||||
ip: "آدرس آی پی",
|
||||
user_agent: "نماینده کاربر",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "دستگاه |||| دستگاه ها",
|
||||
fields: {
|
||||
device_id: "شناسه دستگاه",
|
||||
display_name: "نام دستگاه",
|
||||
last_seen_ts: "مهر زمان",
|
||||
last_seen_ip: "آدرس آی پی",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "حذف کردن %{id}",
|
||||
content:
|
||||
'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
|
||||
success: "دستگاه با موفقیت حذف شد.",
|
||||
failure: "خطایی رخ داده است.",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "رسانه ها",
|
||||
fields: {
|
||||
media_id: "شناسه رسانه",
|
||||
media_length: "اندازه فایل (به بایت)",
|
||||
media_type: "نوع",
|
||||
upload_name: "نام فایل",
|
||||
quarantined_by: "قرنطینه شده توسط",
|
||||
safe_from_quarantine: "امان از قرنطینه",
|
||||
created_ts: "ایجاد شده",
|
||||
last_access_ts: "آخرین دسترسی",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "رسانه ها",
|
||||
fields: {
|
||||
before_ts: "آخرین دسترسی قبل",
|
||||
size_gt: "بزرگتر از آن (به بایت)",
|
||||
keep_profiles: "تصاویر پروفایل را نگه دارید",
|
||||
},
|
||||
action: {
|
||||
send: "حذف رسانه ها",
|
||||
send_success: "درخواست با موفقیت ارسال شد.",
|
||||
send_failure: "خطایی رخ داده است.",
|
||||
},
|
||||
helper: {
|
||||
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانههایی که در مخازن رسانه خارجی آپلود شدهاند تأثیری نخواهد گذاشت.",
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "محافظت نشده، حفاظت ایجاد کنید",
|
||||
delete: "محافظت شده، حفاظت را بردارید",
|
||||
none: "در قرنطینه",
|
||||
send_success: "وضعیت حفاظت با موفقیت تغییر کرد.",
|
||||
send_failure: "خطایی رخ داده است.",
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "قرنطینه",
|
||||
create: "به قرنطینه اضافه کنید",
|
||||
delete: "در قرنطینه، غیر قرنطینه",
|
||||
none: "از قرنطینه محافظت می شود",
|
||||
send_success: "وضعیت قرنطینه با موفقیت تغییر کرد.",
|
||||
send_failure: "خطایی رخ داده است.",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "هل دهنده |||| هل دهنده ها",
|
||||
fields: {
|
||||
app: "برنامه",
|
||||
app_display_name: "نام نمایش برنامه",
|
||||
app_id: "شناسه برنامه",
|
||||
device_display_name: "نام نمایشی برنامه",
|
||||
kind: "نوع",
|
||||
lang: "زبان",
|
||||
profile_tag: "برچسب پروفایل",
|
||||
pushkey: "کلید",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "اطلاعیه های سرور",
|
||||
send: "ارسال اعلانات سرور",
|
||||
fields: {
|
||||
body: "پیام",
|
||||
},
|
||||
action: {
|
||||
send: "ارسال یادداشت",
|
||||
send_success: "اعلان سرور با موفقیت ارسال شد.",
|
||||
send_failure: "خطایی رخ داده است.",
|
||||
},
|
||||
helper: {
|
||||
send: "اعلان سرور را برای کاربران انتخاب شده ارسال می کند. ویژگی 'اعلامیه های سرور' باید در سرور فعال شود.",
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "رسانه کاربران",
|
||||
fields: {
|
||||
media_count: "شمارش رسانه ها",
|
||||
media_length: "طول رسانه",
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Forward Extremities",
|
||||
fields: {
|
||||
id: "شناسه رویداد",
|
||||
received_ts: "مهر زمان",
|
||||
depth: "عمق",
|
||||
state_group: "گروه دولتی",
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "رویدادهای وضعیت",
|
||||
fields: {
|
||||
type: "نوع",
|
||||
content: "محتوا",
|
||||
origin_server_ts: "زمان ارسال",
|
||||
sender: "فرستنده",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "راهنمای اتاق",
|
||||
fields: {
|
||||
world_readable: "کاربران مهمان می توانند بدون عضویت مشاهده کنند",
|
||||
guest_can_join: "کاربران مهمان ممکن است ملحق شوند",
|
||||
},
|
||||
action: {
|
||||
title:
|
||||
"اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
|
||||
content:
|
||||
"آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟",
|
||||
erase: "حذف از فهرست اتاق",
|
||||
create: "انتشار در راهنما اتاق",
|
||||
send_success: "اتاق با موفقیت منتشر شد.",
|
||||
send_failure: "خطایی رخ داده است.",
|
||||
},
|
||||
},
|
||||
destinations: {
|
||||
name: "سرور های مرتبط",
|
||||
fields: {
|
||||
destination: "آدرس",
|
||||
failure_ts: "زمان شکست",
|
||||
retry_last_ts: "آخرین زمان اتصال",
|
||||
retry_interval: "بازه امتحان مجدد",
|
||||
last_successful_stream_ordering: "آخرین جریان موفق",
|
||||
stream_ordering: "جریان",
|
||||
},
|
||||
action: { reconnect: "دوباره وصل شوید" },
|
||||
},
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "توکن های ثبت نام",
|
||||
fields: {
|
||||
token: "توکن",
|
||||
valid: "توکن معتبر",
|
||||
uses_allowed: "موارد استفاده مجاز",
|
||||
pending: "انتظار",
|
||||
completed: "تکمیل شد",
|
||||
expiry_time: "زمان انقضا",
|
||||
length: "طول",
|
||||
},
|
||||
helper: { length: "طول توکن در صورت عدم ارائه توکن." },
|
||||
},
|
||||
};
|
||||
export default fa;
|
377
src/i18n/fr.js
Normal file
377
src/i18n/fr.js
Normal file
@ -0,0 +1,377 @@
|
||||
import frenchMessages from "ra-language-french";
|
||||
|
||||
const fr = {
|
||||
...frenchMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "URL du serveur d’accueil",
|
||||
welcome: "Bienvenue sur Synapse-admin",
|
||||
server_version: "Version du serveur Synapse",
|
||||
username_error:
|
||||
"Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
|
||||
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
||||
url_error: "L'URL du serveur Matrix n'est pas valide",
|
||||
sso_sign_in: "Se connecter avec l’authentification unique",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id:
|
||||
"Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||
tabs: { sso: "Authentification unique" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "Informations de base",
|
||||
members: "Membres",
|
||||
detail: "Détails",
|
||||
permission: "Permissions",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "Pour l'entrée %{entry} : %{message}",
|
||||
error: "Erreur",
|
||||
required_field: "Le champ requis « %{field} » est manquant",
|
||||
invalid_value:
|
||||
"Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »",
|
||||
unreasonably_big:
|
||||
"Refus de charger un fichier trop volumineux de %{size} mégaoctets",
|
||||
already_in_progress: "Un import est déjà en cours",
|
||||
id_exits: "L'identifiant %{id} déjà présent",
|
||||
},
|
||||
title: "Importer des utilisateurs à partir d'un fichier CSV",
|
||||
goToPdf: "Voir le PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Importer des utilisateurs",
|
||||
users_total:
|
||||
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
|
||||
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
|
||||
admin_count:
|
||||
"%{smart_count} administrateur |||| %{smart_count} administrateurs",
|
||||
},
|
||||
conflicts: {
|
||||
header: "Stratégie de résolution des conflits",
|
||||
mode: {
|
||||
stop: "S'arrêter en cas de conflit",
|
||||
skip: "Afficher l'erreur et ignorer le conflit",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "Identifiants",
|
||||
all_ids_present: "Identifiants présents pour chaque entrée",
|
||||
count_ids_present:
|
||||
"%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
|
||||
mode: {
|
||||
ignore:
|
||||
"Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
|
||||
update: "Mettre à jour les enregistrements existants",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "Mots de passe",
|
||||
all_passwords_present: "Mots de passe présents pour chaque entrée",
|
||||
count_passwords_present:
|
||||
"%{smart_count} entrée avec mot de passe |||| %{smart_count} entrées avec mot de passe",
|
||||
use_passwords: "Utiliser les mots de passe provenant du fichier CSV",
|
||||
},
|
||||
upload: {
|
||||
header: "Fichier CSV en entrée",
|
||||
explanation:
|
||||
"Vous pouvez télécharger ici un fichier contenant des valeurs séparées par des virgules qui sera traité pour créer ou mettre à jour des utilisateurs. Le fichier doit inclure les champs « id » et « displayname ». Vous pouvez télécharger et adapter un fichier d'exemple ici : ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Simuler",
|
||||
run_import: "Importer",
|
||||
},
|
||||
results: {
|
||||
header: "Résultats de l'import",
|
||||
total:
|
||||
"%{smart_count} entrée au total |||| %{smart_count} entrées au total",
|
||||
successful: "%{smart_count} entrées importées avec succès",
|
||||
skipped: "%{smart_count} entrées ignorées",
|
||||
download_skipped: "Télécharger les entrées ignorées",
|
||||
with_error:
|
||||
"%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
|
||||
simulated_only: "L'import était simulé",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Utilisateur |||| Utilisateurs",
|
||||
email: "Adresse électronique",
|
||||
msisdn: "Numéro de téléphone",
|
||||
threepid: "Adresse électronique / Numéro de téléphone",
|
||||
fields: {
|
||||
avatar: "Avatar",
|
||||
id: "Identifiant",
|
||||
name: "Nom",
|
||||
is_guest: "Visiteur",
|
||||
admin: "Administrateur du serveur",
|
||||
deactivated: "Désactivé",
|
||||
guests: "Afficher les visiteurs",
|
||||
show_deactivated: "Afficher les utilisateurs désactivés",
|
||||
user_id: "Rechercher un utilisateur",
|
||||
displayname: "Nom d'affichage",
|
||||
password: "Mot de passe",
|
||||
avatar_url: "URL de l'avatar",
|
||||
avatar_src: "Avatar",
|
||||
medium: "Type",
|
||||
threepids: "Identifiants tiers",
|
||||
address: "Adresse",
|
||||
creation_ts_ms: "Date de création",
|
||||
consent_version: "Version du consentement",
|
||||
auth_provider: "Fournisseur d'identité",
|
||||
},
|
||||
helper: {
|
||||
deactivate:
|
||||
"Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||
},
|
||||
action: {
|
||||
erase: "Effacer les données de l'utilisateur",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "Salon |||| Salons",
|
||||
fields: {
|
||||
room_id: "Identifiant du salon",
|
||||
name: "Nom",
|
||||
canonical_alias: "Alias",
|
||||
joined_members: "Membres",
|
||||
joined_local_members: "Membres locaux",
|
||||
joined_local_devices: "Appareils locaux",
|
||||
state_events: "Événements d'État / Complexité",
|
||||
version: "Version",
|
||||
is_encrypted: "Chiffré",
|
||||
encryption: "Chiffrement",
|
||||
federatable: "Fédérable",
|
||||
public: "Visible dans le répertoire des salons",
|
||||
creator: "Créateur",
|
||||
join_rules: "Règles d'adhésion",
|
||||
guest_access: "Accès des visiteurs",
|
||||
history_visibility: "Visibilité de l'historique",
|
||||
topic: "Sujet",
|
||||
avatar: "Avatar",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
"Les extrémités avant sont les événements feuilles à la fin d'un graphe orienté acyclique (DAG) dans un salon, c'est-à-dire les événements qui n'ont pas de descendants. Plus il y en a dans un salon, plus la résolution d'état que Synapse doit effectuer est importante (indice : c'est une opération coûteuse). Bien que Synapse dispose d'un algorithme pour éviter qu'un trop grand nombre de ces événements n'existent en même temps dans un salon, des bogues peuvent parfois les faire réapparaître. Si un salon présente plus de 10 extrémités avant, cela vaut la peine d'y prêter attention et éventuellement de les supprimer en utilisant les requêtes SQL mentionnées dans la discussion traitant du problème https://github.com/matrix-org/synapse/issues/1760.",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
public: "Public",
|
||||
knock: "Sur demande",
|
||||
invite: "Sur invitation",
|
||||
private: "Privé",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Les visiteurs peuvent rejoindre le salon",
|
||||
forbidden: "Les visiteurs ne peuvent pas rejoindre le salon",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "Depuis l'invitation",
|
||||
joined: "Depuis l'adhésion",
|
||||
shared: "Depuis le partage",
|
||||
world_readable: "Tout le monde",
|
||||
},
|
||||
unencrypted: "Non chiffré",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Supprimer le salon",
|
||||
content:
|
||||
"Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !",
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Événement signalé |||| Événements signalés",
|
||||
fields: {
|
||||
id: "Identifiant",
|
||||
received_ts: "Date du rapport",
|
||||
user_id: "Rapporteur",
|
||||
name: "Nom du salon",
|
||||
score: "Score",
|
||||
reason: "Motif",
|
||||
event_id: "Identifiant de l'événement",
|
||||
event_json: {
|
||||
origin: "Serveur d'origine",
|
||||
origin_server_ts: "Date d'envoi",
|
||||
type: "Type d'événement",
|
||||
content: {
|
||||
msgtype: "Type de contenu",
|
||||
body: "Contenu",
|
||||
format: "Format",
|
||||
formatted_body: "Contenu mis en forme",
|
||||
algorithm: "Algorithme",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
name: "Connexions",
|
||||
fields: {
|
||||
last_seen: "Date",
|
||||
ip: "Adresse IP",
|
||||
user_agent: "Agent utilisateur",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "Appareil |||| Appareils",
|
||||
fields: {
|
||||
device_id: "Identifiant de l'appareil",
|
||||
display_name: "Nom de l'appareil",
|
||||
last_seen_ts: "Date",
|
||||
last_seen_ip: "Adresse IP",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Suppression de %{id}",
|
||||
content: "Voulez-vous vraiment supprimer l'appareil « %{name} » ?",
|
||||
success: "Appareil supprimé avec succès",
|
||||
failure: "Une erreur s'est produite",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "Identifiant du média",
|
||||
media_length: "Taille du fichier (en octets)",
|
||||
media_type: "Type",
|
||||
upload_name: "Nom du fichier",
|
||||
quarantined_by: "Mis en quarantaine par",
|
||||
safe_from_quarantine: "Protection contre la mise en quarantaine",
|
||||
created_ts: "Date de création",
|
||||
last_access_ts: "Dernier accès",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
before_ts: "Dernier accès avant",
|
||||
size_gt: "Plus grand que (en octets)",
|
||||
keep_profiles: "Conserver les images de profil",
|
||||
},
|
||||
action: {
|
||||
send: "Supprimer le média",
|
||||
send_success: "Requête envoyée avec succès",
|
||||
send_failure: "Une erreur s'est produite",
|
||||
},
|
||||
helper: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Protéger",
|
||||
delete: "Révoquer la protection",
|
||||
none: "En quarantaine",
|
||||
send_success: "Le statut de protection a été modifié avec succès",
|
||||
send_failure: "Une erreur s'est produite",
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Quarantaine",
|
||||
create: "Mettre en quarantaine",
|
||||
delete: "Révoquer la mise en quarantaine",
|
||||
none: "Protégé contre la mise en quarantaine",
|
||||
send_success: "Le statut de la quarantaine a été modifié avec succès",
|
||||
send_failure: "Une erreur s'est produite",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "Émetteur de notifications |||| Émetteurs de notifications",
|
||||
fields: {
|
||||
app: "Application",
|
||||
app_display_name: "Nom d'affichage de l'application",
|
||||
app_id: "Identifiant de l'application",
|
||||
device_display_name: "Nom d'affichage de l'appareil",
|
||||
kind: "Type",
|
||||
lang: "Langue",
|
||||
profile_tag: "Profil",
|
||||
pushkey: "Identifiant de l'émetteur",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "Annonces du serveur",
|
||||
send: "Envoyer des « Annonces du serveur »",
|
||||
fields: {
|
||||
body: "Message",
|
||||
},
|
||||
action: {
|
||||
send: "Envoyer une annonce",
|
||||
send_success: "Annonce envoyée avec succès",
|
||||
send_failure: "Une erreur s'est produite",
|
||||
},
|
||||
helper: {
|
||||
send: "Envoie une annonce au nom du serveur aux utilisateurs sélectionnés. La fonction « Annonces du serveur » doit être activée sur le serveur.",
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "Médias des utilisateurs",
|
||||
fields: {
|
||||
media_count: "Nombre de médias",
|
||||
media_length: "Taille des médias",
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Extrémités avant",
|
||||
fields: {
|
||||
id: "Identifiant de l'événement",
|
||||
received_ts: "Date de réception",
|
||||
depth: "Profondeur",
|
||||
state_group: "Groupe d'état",
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "Événements d'état",
|
||||
fields: {
|
||||
type: "Type",
|
||||
content: "Contenu",
|
||||
origin_server_ts: "Date d'envoi",
|
||||
sender: "Expéditeur",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Répertoire des salons",
|
||||
fields: {
|
||||
world_readable:
|
||||
"Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
|
||||
guest_can_join: "Les visiteurs peuvent rejoindre le salon",
|
||||
},
|
||||
action: {
|
||||
title:
|
||||
"Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
|
||||
content:
|
||||
"Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?",
|
||||
erase: "Supprimer du répertoire des salons",
|
||||
create: "Publier dans le répertoire des salons",
|
||||
send_success: "Salon publié avec succès",
|
||||
send_failure: "Une erreur s'est produite",
|
||||
},
|
||||
},
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "Jetons d'inscription",
|
||||
fields: {
|
||||
token: "Jeton",
|
||||
valid: "Jeton valide",
|
||||
uses_allowed: "Nombre d'inscription autorisées",
|
||||
pending: "Nombre d'inscription en cours",
|
||||
completed: "Nombre d'inscription accomplie",
|
||||
expiry_time: "Date d'expiration",
|
||||
length: "Longueur",
|
||||
},
|
||||
helper: {
|
||||
length:
|
||||
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
|
||||
},
|
||||
},
|
||||
};
|
||||
export default fr;
|
385
src/i18n/it.js
Normal file
385
src/i18n/it.js
Normal file
@ -0,0 +1,385 @@
|
||||
import italianMessages from "ra-language-italian";
|
||||
|
||||
const it = {
|
||||
...italianMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "URL dell'homeserver",
|
||||
welcome: "Benvenuto in Synapse-admin",
|
||||
server_version: "Versione di Synapse",
|
||||
username_error:
|
||||
"Per favore inserisci un ID utente completo: '@utente:dominio'",
|
||||
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
|
||||
url_error: "URL del server Matrix non valido",
|
||||
sso_sign_in: "Accedi con SSO",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "ID utente non valido su questo homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "Semplice",
|
||||
members: "Membro",
|
||||
detail: "Dettagli",
|
||||
permission: "Permessi",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Semplice", detail: "Dettagli" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "Alla voce %{entry}: %{message}",
|
||||
error: "Errore",
|
||||
required_field: "Il campo '%{field}' non è presente",
|
||||
invalid_value:
|
||||
"Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'",
|
||||
unreasonably_big:
|
||||
"Impossibile caricare un file così grosso (%{size} megabyte)",
|
||||
already_in_progress: "Un import è attualmente già in caricamento",
|
||||
id_exits: "L'ID %{id} è già presente",
|
||||
},
|
||||
title: "Importa utenti tramite file CSV",
|
||||
goToPdf: "Vai al PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Importa utenti",
|
||||
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",
|
||||
},
|
||||
conflicts: {
|
||||
header: "Strategia di conflitto",
|
||||
mode: {
|
||||
stop: "Stoppa al conflitto",
|
||||
skip: "Mostra l'errore e ignora il conflitto",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "ID",
|
||||
all_ids_present: "ID presenti in ogni voce",
|
||||
count_ids_present:
|
||||
"%{smart_count} voce con ID |||| %{smart_count} voci con ID",
|
||||
mode: {
|
||||
ignore: "Ignora gli ID nel file CSV e creane di nuovi",
|
||||
update: "Aggiorna le voci esistenti",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "Passwords",
|
||||
all_passwords_present: "Password presenti in ogni voce",
|
||||
count_passwords_present:
|
||||
"%{smart_count} voce con password |||| %{smart_count} voci con password",
|
||||
use_passwords: "Usa le password dal file CSV",
|
||||
},
|
||||
upload: {
|
||||
header: "Input file CSV",
|
||||
explanation:
|
||||
"Qui puoi caricare un file con valori separati da virgole che verrà poi utilizzato per creare o aggiornare gli utenti. Il file deve includere i campi 'id' and 'displayname'. Puoi scaricare un file di esempio per adattarlo: ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Solo simulazione",
|
||||
run_import: "Importa",
|
||||
},
|
||||
results: {
|
||||
header: "Importa i risultati",
|
||||
total:
|
||||
"%{smart_count} voce in totale |||| %{smart_count} voci in totale",
|
||||
successful: "%{smart_count} voci importate con successo",
|
||||
skipped: "%{smart_count} voci ignorate",
|
||||
download_skipped: "Scarica le voci ignorate",
|
||||
with_error:
|
||||
"%{smart_count} voce con errori ||| %{smart_count} voci con errori",
|
||||
simulated_only: "Il processo era stato solamente simulato",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Utente |||| Utenti",
|
||||
email: "Email",
|
||||
msisdn: "Telefono",
|
||||
threepid: "Email / Telefono",
|
||||
fields: {
|
||||
avatar: "Avatar",
|
||||
id: "ID utente",
|
||||
name: "Nome",
|
||||
is_guest: "Ospite",
|
||||
admin: "Amministratore",
|
||||
deactivated: "Disattivato",
|
||||
guests: "Mostra gli ospiti",
|
||||
show_deactivated: "Mostra gli utenti disattivati",
|
||||
user_id: "Cerca utente",
|
||||
displayname: "Nickname",
|
||||
password: "Password",
|
||||
avatar_url: "URL dell'avatar",
|
||||
avatar_src: "Avatar",
|
||||
medium: "Medium",
|
||||
threepids: "3PID",
|
||||
address: "Indirizzo",
|
||||
creation_ts_ms: "Creazione del timestamp",
|
||||
consent_version: "Versione minima richiesta",
|
||||
auth_provider: "Provider",
|
||||
user_type: "Tipo d'utente",
|
||||
},
|
||||
helper: {
|
||||
password:
|
||||
"Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
||||
deactivate: "Devi fornire una password per riattivare l'account.",
|
||||
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
||||
},
|
||||
action: {
|
||||
erase: "Cancella i dati dell'utente",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "Stanza |||| Stanze",
|
||||
fields: {
|
||||
room_id: "ID della stanza",
|
||||
name: "Nome",
|
||||
canonical_alias: "Alias",
|
||||
joined_members: "Membri",
|
||||
joined_local_members: "Membri locali",
|
||||
joined_local_devices: "Dispositivi locali",
|
||||
state_events: "Eventi di stato / Complessità",
|
||||
version: "Versione",
|
||||
is_encrypted: "Criptato",
|
||||
encryption: "Crittografia",
|
||||
federatable: "Federabile",
|
||||
public: "Visibile nella cartella della stanza",
|
||||
creator: "Creatore",
|
||||
join_rules: "Regole per entrare",
|
||||
guest_access: "Entra come ospite",
|
||||
history_visibility: "Visibilità temporale",
|
||||
topic: "Topic",
|
||||
avatar: "Avatar",
|
||||
},
|
||||
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: {
|
||||
join_rules: {
|
||||
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",
|
||||
},
|
||||
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!",
|
||||
},
|
||||
},
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
before_ts: "ultimo accesso effettuato prima",
|
||||
size_gt: "Più grande di (in byte)",
|
||||
keep_profiles: "Mantieni le immagini del profilo",
|
||||
},
|
||||
action: {
|
||||
send: "Cancella media",
|
||||
send_success: "Richiesta inviata con successo.",
|
||||
send_failure: "C'è stato un errore.",
|
||||
},
|
||||
helper: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
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.",
|
||||
},
|
||||
},
|
||||
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.",
|
||||
},
|
||||
},
|
||||
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" },
|
||||
},
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
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_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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
helper: { length: "Lunghezza del token se non viene dato alcun token." },
|
||||
},
|
||||
};
|
||||
export default it;
|
390
src/i18n/ru.js
Normal file
390
src/i18n/ru.js
Normal file
@ -0,0 +1,390 @@
|
||||
import russianMessages from "ra-language-russian";
|
||||
|
||||
const ru = {
|
||||
...russianMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Домашняя страница",
|
||||
welcome: "Добро пожаловать в Synapse-admin",
|
||||
server_version: "Версия Synapse",
|
||||
supports_specs: "поддерживает спецификации Matrix",
|
||||
username_error: "Введите полный идентификатор пользователя: '@user:domain'",
|
||||
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
|
||||
url_error: "Некорректный сервер Matrix",
|
||||
sso_sign_in: "Присоединиться с помощью SSO",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Локальная часть идентификатора пользователя Matrix без домашнего сервера.",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "Основное",
|
||||
members: "Участники",
|
||||
detail: "Подробности",
|
||||
permission: "Права",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Основное", detail: "Подробности" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "При входе %{entry}: %{message}",
|
||||
error: "Ошибка",
|
||||
required_field: "Обязательное поле '%{field}' не представленно",
|
||||
invalid_value:
|
||||
"Недопустимое значение в строке %{row}. '%{field}' поле может быть только 'true' или 'false'",
|
||||
unreasonably_big:
|
||||
"Отказался загружать неоправданно большой файл %{size} Мбайт",
|
||||
already_in_progress: "Импорт уже запущен",
|
||||
id_exits: "Идентификатор %{id} уже существует",
|
||||
},
|
||||
title: "Импорт пользователей из CSV",
|
||||
goToPdf: "В PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "Импорт пользователей",
|
||||
users_total:
|
||||
"%{smart_count} пользователь в CSV файл |||| %{smart_count} пользователей в CSV файл",
|
||||
guest_count: "%{smart_count} гость |||| %{smart_count} гостей",
|
||||
admin_count: "%{smart_count} администратор |||| %{smart_count} администраторов",
|
||||
},
|
||||
conflicts: {
|
||||
header: "Решение конфликтов",
|
||||
mode: {
|
||||
stop: "Остановиться, если конфликт произошел",
|
||||
skip: "Вывести ошибку и пропустить конфликт",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "Идентификаторы",
|
||||
all_ids_present: "Идентификаторы представлены для каждой записи",
|
||||
count_ids_present:
|
||||
"%{smart_count} запись с идентификатором |||| %{smart_count} записей с идентификатором",
|
||||
mode: {
|
||||
ignore: "Игнорировать идентификаторы в CSV и создавать новые",
|
||||
update: "Обновлять существующие записи",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "Пароли",
|
||||
all_passwords_present: "Пароли представлены для каждой записи",
|
||||
count_passwords_present:
|
||||
"%{smart_count} запись с паролем |||| %{smart_count} записей с паролем",
|
||||
use_passwords: "Использовать пароли из CSV",
|
||||
},
|
||||
upload: {
|
||||
header: "Загрузка CSV файла",
|
||||
explanation:
|
||||
"Здесь вы можете загрузить файл со значениями, разделенными запятыми, который будет обработан для создания или обновления пользователей. Файл должен содержать поля 'id' и 'displayname'. Вы можете загрузить пример здесь:",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Не выполнять реальных действий",
|
||||
run_import: "Импорт",
|
||||
},
|
||||
results: {
|
||||
header: "Импорт результатов",
|
||||
total:
|
||||
"Всего %{smart_count} запись |||| Всего %{smart_count} записей",
|
||||
successful: "%{smart_count} записей успешно импортировано",
|
||||
skipped: "%{smart_count} записей пропущено",
|
||||
download_skipped: "Загрузить пропущенные записи",
|
||||
with_error:
|
||||
"%{smart_count} запись с ошибками ||| %{smart_count} записей с ошибками",
|
||||
simulated_only: "Результат не будет сохранен",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Пользователей |||| Пользователи",
|
||||
email: "Почта",
|
||||
msisdn: "Телефон",
|
||||
threepid: "Почта / Телефон",
|
||||
fields: {
|
||||
avatar: "Аватар",
|
||||
id: "Идентификатор пользователя",
|
||||
name: "Имя",
|
||||
is_guest: "Гость",
|
||||
admin: "Администратор",
|
||||
deactivated: "Деактивирован",
|
||||
guests: "Показать гостей",
|
||||
show_deactivated: "Показать деактивированных пользователей",
|
||||
user_id: "Найти пользователя",
|
||||
displayname: "Отображаемое имя",
|
||||
password: "Пароль",
|
||||
avatar_url: "Ссылка на аватар",
|
||||
avatar_src: "Аватар",
|
||||
medium: "Тип",
|
||||
threepids: "Иной идентификатор",
|
||||
address: "Адрес",
|
||||
creation_ts_ms: "Время создания",
|
||||
consent_version: "Версия соглашения",
|
||||
auth_provider: "Поставщик",
|
||||
user_type: "Тип пользователя",
|
||||
},
|
||||
helper: {
|
||||
password: "Изменение пароля приведет к выходу пользователя из всех сеансов.",
|
||||
deactivate: "Вы должны ввести пароль для повторной активации учетной записи.",
|
||||
erase: "Пометить пользователя как удаленного в связи с защитой персональных данных",
|
||||
},
|
||||
action: {
|
||||
erase: "Удаление пользовательских данных",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "Комнат |||| Комнаты",
|
||||
fields: {
|
||||
room_id: "Идентификатор комнаты",
|
||||
name: "Название",
|
||||
canonical_alias: "Псевдоним",
|
||||
joined_members: "Участники",
|
||||
joined_local_members: "Внутренние участники",
|
||||
joined_local_devices: "Используемые устройства",
|
||||
state_events: "События изменения состояния",
|
||||
version: "Версия",
|
||||
is_encrypted: "Зашифрованно",
|
||||
encryption: "Шифрование",
|
||||
federatable: "Федерация",
|
||||
public: "Видимость в списке комнат",
|
||||
creator: "Создатель",
|
||||
join_rules: "Правила присоединения",
|
||||
guest_access: "Гостевой доступ",
|
||||
history_visibility: "Видимость истории",
|
||||
topic: "Тема",
|
||||
avatar: "Аватар",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
"Перенаправленные заключения, это такие события, которые не имеют потомков в рамках графа, отвечающего за их репрезентацию. Чем больше пользователей находится в комнате, тем больше операций требуется выполнить Synapse для разрешения коллиций, которые возникают при их проверке наступления событий (это дорогостоящая операция). Хотя в Synapse есть код, предотвращающий одновременное присутствие слишком большого количества таких объектов в комнате, ошибки иногда могут привести к их повторному появлению. Если в комнате содержится >10 перенаправленных заключений, имеет смысл выяснить, какая комната стала причиной их появления и, возможно, удалить их, используя SQL-запросы, упомянутые issue #1760.",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
public: "Публичный",
|
||||
knock: "По запросу",
|
||||
invite: "По приглашению",
|
||||
private: "Приватный",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "Гости могут присоединиться",
|
||||
forbidden: "Гости не могут присоединиться",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "С момента приглашения",
|
||||
joined: "С момента присоединения",
|
||||
shared: "С момента разрешения",
|
||||
world_readable: "Всегда",
|
||||
},
|
||||
unencrypted: "Не зашифрованно",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Удалить комнату",
|
||||
content:
|
||||
"Вы уверены, что хотите удалить комнату? Это невозможно отменить. Все сообщения и медиафайлы, находящиеся в общем доступе в комнате, будут удалены с сервера!",
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Жалоб |||| Жалобы",
|
||||
fields: {
|
||||
id: "идентификатор",
|
||||
received_ts: "время жалобы",
|
||||
user_id: "коментатор",
|
||||
name: "название комнаты",
|
||||
score: "оценка",
|
||||
reason: "причина",
|
||||
event_id: "идентификатор события",
|
||||
event_json: {
|
||||
origin: "сервер",
|
||||
origin_server_ts: "время отправки",
|
||||
type: "тип события",
|
||||
content: {
|
||||
msgtype: "тип содержания",
|
||||
body: "содержание",
|
||||
format: "формат",
|
||||
formatted_body: "форматированное содержание",
|
||||
algorithm: "алгоритм",
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Удалить жалобу",
|
||||
content:
|
||||
"Вы уверены, что хотите удалить жалобу? Это невозможно отменить.",
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
name: "Подключения",
|
||||
fields: {
|
||||
last_seen: "Дата",
|
||||
ip: "IP адрес",
|
||||
user_agent: "User agent",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "Устройств |||| Устройства",
|
||||
fields: {
|
||||
device_id: "Идентификатор устройства",
|
||||
display_name: "Название устройства",
|
||||
last_seen_ts: "Метка времени",
|
||||
last_seen_ip: "IP адрес",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "Удаление %{id}",
|
||||
content: 'Вы уверены, что хотите удалить устройство "%{name}"?',
|
||||
success: "Устройство успешно удалено.",
|
||||
failure: "Произошла ошибка.",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "Медиа",
|
||||
fields: {
|
||||
media_id: "Идентификатор медиа",
|
||||
media_length: "Размер файла (в байтах)",
|
||||
media_type: "Тип",
|
||||
upload_name: "Имя файла",
|
||||
quarantined_by: "Отправлен в карантин",
|
||||
safe_from_quarantine: "Защищен от карантина",
|
||||
created_ts: "Создан",
|
||||
last_access_ts: "Последнее обращение",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "Медиа",
|
||||
fields: {
|
||||
before_ts: "последнее обращение",
|
||||
size_gt: "Больше чем (в байтах)",
|
||||
keep_profiles: "Сохранить аватары",
|
||||
},
|
||||
action: {
|
||||
send: "Удалить медиафайлы",
|
||||
send_success: "Запрос отправлен.",
|
||||
send_failure: "Произошла ошибка.",
|
||||
},
|
||||
helper: {
|
||||
send: "Этот метод удаляет медиафайлы с диска сервера. Это включает в себя любые миниатюры и копии загруженных файлов. Этот метод не повлияет на файлы, загруженные во внешние хранилища.",
|
||||
},
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Не защищено, установить защиту",
|
||||
delete: "Защищено, удалить защиту",
|
||||
none: "В карантине",
|
||||
send_success: "Успешно изменен статус защиты.",
|
||||
send_failure: "Произошла ошибка.",
|
||||
},
|
||||
},
|
||||
quarantine_media: {
|
||||
action: {
|
||||
name: "Карантин",
|
||||
create: "Добавить в карантин",
|
||||
delete: "В карантине, вывести из карантина",
|
||||
none: "Защищено от карантина",
|
||||
send_success: "Успешно изменен статус карантина.",
|
||||
send_failure: "Произошла ошибка.",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "Уведомление |||| Уведомления",
|
||||
fields: {
|
||||
app: "Приложение",
|
||||
app_display_name: "Отображаемое имя приложения",
|
||||
app_id: "Идентификатор приложения",
|
||||
device_display_name: "Отображаемое имя устройства",
|
||||
kind: "Тип",
|
||||
lang: "Язык",
|
||||
profile_tag: "Тег профиля",
|
||||
pushkey: "Токен доступа",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "Уведомления",
|
||||
send: "Отправить уведомление",
|
||||
fields: {
|
||||
body: "Сообщение",
|
||||
},
|
||||
action: {
|
||||
send: "Отправить",
|
||||
send_success: "Уведомление успешно отправлено.",
|
||||
send_failure: "Произошла ошибка.",
|
||||
},
|
||||
helper: {
|
||||
send: 'Отправляет уведомление выбранным пользователям. Функция "Server Notices" должна быть активирована на сервере.',
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "Медиа",
|
||||
fields: {
|
||||
media_count: "Количество медиафайлов",
|
||||
media_length: "Длина медиафайлов",
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
name: "Перенаправленные заключения",
|
||||
fields: {
|
||||
id: "Идентификатор события",
|
||||
received_ts: "Время",
|
||||
depth: "Глубина",
|
||||
state_group: "Группа состояния",
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
name: "События изменения состояния",
|
||||
fields: {
|
||||
type: "Тип",
|
||||
content: "Содержание",
|
||||
origin_server_ts: "время отправления",
|
||||
sender: "Отправитель",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Публичных комнат |||| Публичные комнаты",
|
||||
fields: {
|
||||
world_readable: "Видимо для гостей",
|
||||
guest_can_join: "Гости могут присоединиться",
|
||||
},
|
||||
action: {
|
||||
title:
|
||||
"Удалить комнату |||| Удалить %{smart_count} комнат",
|
||||
content:
|
||||
"Вы уверены, что хотите удалить комнату? |||| Вы уверены, что хотите удалить %{smart_count} комнат?",
|
||||
erase: "Удалить комнату",
|
||||
create: "Опубликовать комнату",
|
||||
send_success: "Комната успешно опубликована.",
|
||||
send_failure: "Произошла ошибка.",
|
||||
},
|
||||
},
|
||||
destinations: {
|
||||
name: "Федераций |||| Федерация",
|
||||
fields: {
|
||||
destination: "Назначение",
|
||||
failure_ts: "Время сбоя",
|
||||
retry_last_ts: "Время последней попытки",
|
||||
retry_interval: "Интервал повторения",
|
||||
last_successful_stream_ordering: "Последнее успешное соединение",
|
||||
stream_ordering: "Соединение",
|
||||
},
|
||||
action: { reconnect: "Переподключиться" },
|
||||
},
|
||||
},
|
||||
registration_tokens: {
|
||||
name: "Токены регистрации",
|
||||
fields: {
|
||||
token: "Токен",
|
||||
valid: "Допустимый токен",
|
||||
uses_allowed: "Разрешено",
|
||||
pending: "Ожидается",
|
||||
completed: "Использован",
|
||||
expiry_time: "Время истечения срока действия",
|
||||
length: "Длина",
|
||||
},
|
||||
helper: { length: "Длина токена, если токен не указан." },
|
||||
},
|
||||
};
|
||||
export default ru;
|
290
src/i18n/zh.js
Normal file
290
src/i18n/zh.js
Normal file
@ -0,0 +1,290 @@
|
||||
import chineseMessages from "ra-language-chinese";
|
||||
|
||||
const zh = {
|
||||
...chineseMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "服务器 URL",
|
||||
welcome: "欢迎来到 Synapse-admin",
|
||||
server_version: "Synapse 版本",
|
||||
username_error: "请输入完整有效的用户 ID: '@user:domain'",
|
||||
protocol_error: "URL 需要以'http://'或'https://'作为起始",
|
||||
url_error: "不是一个有效的 Matrix 服务器地址",
|
||||
sso_sign_in: "使用 SSO 登录",
|
||||
},
|
||||
users: {
|
||||
invalid_user_id:
|
||||
"必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
||||
tabs: { sso: "SSO" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
basic: "基本",
|
||||
members: "成员",
|
||||
detail: "细节",
|
||||
permission: "权限",
|
||||
},
|
||||
delete: {
|
||||
title: "删除房间",
|
||||
message:
|
||||
"您确定要删除这个房间吗?该操作无法被撤销。这个房间里所有的消息和分享的媒体都将被从服务器上删除!",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "基本", detail: "细节" } },
|
||||
},
|
||||
import_users: {
|
||||
error: {
|
||||
at_entry: "在条目 %{entry}: %{message}",
|
||||
error: "错误",
|
||||
required_field: "需要的值 '%{field}' 未被设置。",
|
||||
invalid_value:
|
||||
"第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
|
||||
unreasonably_big: "拒绝加载过大的文件: %{size} MB",
|
||||
already_in_progress: "一个导入进程已经在运行中",
|
||||
id_exits: "ID %{id} 已经存在",
|
||||
},
|
||||
title: "通过 CSV 导入用户",
|
||||
goToPdf: "转到 PDF",
|
||||
cards: {
|
||||
importstats: {
|
||||
header: "导入用户",
|
||||
users_total:
|
||||
"%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
|
||||
guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客",
|
||||
admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员",
|
||||
},
|
||||
conflicts: {
|
||||
header: "冲突处理策略",
|
||||
mode: {
|
||||
stop: "在冲突处停止",
|
||||
skip: "显示错误并跳过冲突",
|
||||
},
|
||||
},
|
||||
ids: {
|
||||
header: "IDs",
|
||||
all_ids_present: "每条记录的 ID",
|
||||
count_ids_present:
|
||||
"%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
|
||||
mode: {
|
||||
ignore: "忽略 CSV 中的 ID 并创建新的",
|
||||
update: "更新已经存在的记录",
|
||||
},
|
||||
},
|
||||
passwords: {
|
||||
header: "密码",
|
||||
all_passwords_present: "每条记录的密码",
|
||||
count_passwords_present:
|
||||
"%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
|
||||
use_passwords: "使用 CSV 中标记的密码",
|
||||
},
|
||||
upload: {
|
||||
header: "导入 CSV 文件",
|
||||
explanation:
|
||||
"在这里,你可以上传一个用逗号分隔的文件,用于创建或更新用户。该文件必须包括 'id' 和 'displayname' 字段。你可以在这里下载并修改一个示例文件:",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "模拟模式",
|
||||
run_import: "导入",
|
||||
},
|
||||
results: {
|
||||
header: "导入结果",
|
||||
total: "共计 %{smart_count} 条记录 |||| 共计 %{smart_count} 条记录",
|
||||
successful: "%{smart_count} 条记录导入成功",
|
||||
skipped: "跳过 %{smart_count} 条记录",
|
||||
download_skipped: "下载跳过的记录",
|
||||
with_error:
|
||||
"%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
|
||||
simulated_only: "只是一次模拟运行",
|
||||
},
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "用户",
|
||||
email: "邮箱",
|
||||
msisdn: "电话",
|
||||
threepid: "邮箱 / 电话",
|
||||
fields: {
|
||||
avatar: "邮箱",
|
||||
id: "用户 ID",
|
||||
name: "用户名",
|
||||
is_guest: "访客",
|
||||
admin: "服务器管理员",
|
||||
deactivated: "被禁用",
|
||||
guests: "显示访客",
|
||||
show_deactivated: "显示被禁用的账户",
|
||||
user_id: "搜索用户",
|
||||
displayname: "显示名字",
|
||||
password: "密码",
|
||||
avatar_url: "头像 URL",
|
||||
avatar_src: "头像",
|
||||
medium: "Medium",
|
||||
threepids: "3PIDs",
|
||||
address: "地址",
|
||||
creation_ts_ms: "创建时间戳",
|
||||
consent_version: "协议版本",
|
||||
},
|
||||
helper: {
|
||||
deactivate: "您必须提供一串密码来激活账户。",
|
||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||
},
|
||||
action: {
|
||||
erase: "抹除用户信息",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
name: "房间",
|
||||
fields: {
|
||||
room_id: "房间 ID",
|
||||
name: "房间名",
|
||||
canonical_alias: "别名",
|
||||
joined_members: "成员",
|
||||
joined_local_members: "本地成员",
|
||||
state_events: "状态事件",
|
||||
version: "版本",
|
||||
is_encrypted: "已经加密",
|
||||
encryption: "加密",
|
||||
federatable: "可联合的",
|
||||
public: "公开",
|
||||
creator: "创建者",
|
||||
join_rules: "加入规则",
|
||||
guest_access: "访客访问",
|
||||
history_visibility: "历史可见性",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
public: "公开",
|
||||
knock: "申请",
|
||||
invite: "邀请",
|
||||
private: "私有",
|
||||
},
|
||||
guest_access: {
|
||||
can_join: "访客可以加入",
|
||||
forbidden: "访客不可加入",
|
||||
},
|
||||
history_visibility: {
|
||||
invited: "自从被邀请",
|
||||
joined: "自从加入",
|
||||
shared: "自从分享",
|
||||
world_readable: "任何人",
|
||||
},
|
||||
unencrypted: "未加密",
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "报告事件",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "报告时间",
|
||||
user_id: "报告者",
|
||||
name: "房间名",
|
||||
score: "分数",
|
||||
reason: "原因",
|
||||
event_id: "事件 ID",
|
||||
event_json: {
|
||||
origin: "原始服务器",
|
||||
origin_server_ts: "发送时间",
|
||||
type: "事件类型",
|
||||
content: {
|
||||
msgtype: "内容类型",
|
||||
body: "内容",
|
||||
format: "格式",
|
||||
formatted_body: "格式化的数据",
|
||||
algorithm: "算法",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
name: "连接",
|
||||
fields: {
|
||||
last_seen: "日期",
|
||||
ip: "IP 地址",
|
||||
user_agent: "用户代理 (UA)",
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
name: "设备",
|
||||
fields: {
|
||||
device_id: "设备 ID",
|
||||
display_name: "设备名",
|
||||
last_seen_ts: "时间戳",
|
||||
last_seen_ip: "IP 地址",
|
||||
},
|
||||
action: {
|
||||
erase: {
|
||||
title: "移除 %{id}",
|
||||
content: '您确定要移除设备 "%{name}"?',
|
||||
success: "设备移除成功。",
|
||||
failure: "出现了一个错误。",
|
||||
},
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
name: "媒体文件",
|
||||
fields: {
|
||||
media_id: "媒体文件 ID",
|
||||
media_length: "长度",
|
||||
media_type: "类型",
|
||||
upload_name: "文件名",
|
||||
quarantined_by: "被隔离",
|
||||
safe_from_quarantine: "取消隔离",
|
||||
created_ts: "创建",
|
||||
last_access_ts: "上一次访问",
|
||||
},
|
||||
},
|
||||
delete_media: {
|
||||
name: "媒体文件",
|
||||
fields: {
|
||||
before_ts: "最后访问时间",
|
||||
size_gt: "大于 (字节)",
|
||||
keep_profiles: "保留头像",
|
||||
},
|
||||
action: {
|
||||
send: "删除媒体",
|
||||
send_success: "请求发送成功。",
|
||||
send_failure: "出现了一个错误。",
|
||||
},
|
||||
helper: {
|
||||
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
name: "发布者",
|
||||
fields: {
|
||||
app: "App",
|
||||
app_display_name: "App 名称",
|
||||
app_id: "App ID",
|
||||
device_display_name: "设备显示名",
|
||||
kind: "类型",
|
||||
lang: "语言",
|
||||
profile_tag: "数据标签",
|
||||
pushkey: "Pushkey",
|
||||
data: { url: "URL" },
|
||||
},
|
||||
},
|
||||
servernotices: {
|
||||
name: "服务器提示",
|
||||
send: "发送服务器提示",
|
||||
fields: {
|
||||
body: "信息",
|
||||
},
|
||||
action: {
|
||||
send: "发送提示",
|
||||
send_success: "服务器提示发送成功。",
|
||||
send_failure: "出现了一个错误。",
|
||||
},
|
||||
helper: {
|
||||
send: '向选中的用户发送服务器提示。服务器配置中的 "服务器提示(Server Notices)" 选项需要被设置为启用。',
|
||||
},
|
||||
},
|
||||
user_media_statistics: {
|
||||
name: "用户的媒体文件",
|
||||
fields: {
|
||||
media_count: "媒体文件统计",
|
||||
media_length: "媒体文件长度",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default zh;
|
@ -1,5 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
9
src/index.jsx
Normal file
9
src/index.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
@ -1,4 +1,3 @@
|
||||
import { configure } from "enzyme";
|
||||
import Adapter from "enzyme-adapter-react-16";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
|
||||
configure({ adapter: new Adapter() });
|
||||
fetchMock.enableMocks();
|
||||
|
@ -1,54 +1,69 @@
|
||||
import { fetchUtils } from "react-admin";
|
||||
|
||||
const ensureHttpsForUrl = url => {
|
||||
if (/^https:\/\//i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
const domain = url.replace(/http.?:\/\//g, "");
|
||||
return "https://" + domain;
|
||||
};
|
||||
|
||||
const stripTrailingSlash = str => {
|
||||
if (!str) {
|
||||
return;
|
||||
}
|
||||
return str.endsWith("/") ? str.slice(0, -1) : str;
|
||||
};
|
||||
|
||||
const authProvider = {
|
||||
// called when the user attempts to log in
|
||||
login: ({ homeserver, username, password }) => {
|
||||
login: async ({ base_url, username, password, loginToken }) => {
|
||||
// force homeserver for protection in case the form is manipulated
|
||||
base_url = process.env.REACT_APP_SERVER || base_url;
|
||||
|
||||
console.log("login ");
|
||||
const options = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(
|
||||
Object.assign(
|
||||
{
|
||||
device_id: localStorage.getItem("device_id"),
|
||||
initial_device_display_name: "Synapse Admin",
|
||||
},
|
||||
loginToken
|
||||
? {
|
||||
type: "m.login.token",
|
||||
token: loginToken,
|
||||
}
|
||||
: {
|
||||
type: "m.login.password",
|
||||
user: username,
|
||||
password: password,
|
||||
}),
|
||||
}
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
const url = window.decodeURIComponent(homeserver);
|
||||
const trimmed_url = url.trim().replace(/\s/g, "");
|
||||
const login_api_url =
|
||||
ensureHttpsForUrl(trimmed_url) + "/_matrix/client/r0/login";
|
||||
// use the base_url from login instead of the well_known entry from the
|
||||
// server, since the admin might want to access the admin API via some
|
||||
// private address
|
||||
base_url = base_url.replace(/\/+$/g, "");
|
||||
localStorage.setItem("base_url", base_url);
|
||||
|
||||
return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => {
|
||||
const normalized_base_url = stripTrailingSlash(
|
||||
json.well_known["m.homeserver"].base_url
|
||||
);
|
||||
localStorage.setItem("base_url", normalized_base_url);
|
||||
localStorage.setItem("home_server_url", json.home_server);
|
||||
const decoded_base_url = window.decodeURIComponent(base_url);
|
||||
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
|
||||
|
||||
const { json } = await fetchUtils.fetchJson(login_api_url, options);
|
||||
localStorage.setItem("home_server", json.home_server);
|
||||
localStorage.setItem("user_id", json.user_id);
|
||||
localStorage.setItem("access_token", json.access_token);
|
||||
localStorage.setItem("device_id", json.device_id);
|
||||
});
|
||||
},
|
||||
// called when the user clicks on the logout button
|
||||
logout: () => {
|
||||
console.log("logout ");
|
||||
logout: async () => {
|
||||
console.log("logout");
|
||||
|
||||
const logout_api_url =
|
||||
localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
user: {
|
||||
authenticated: true,
|
||||
token: `Bearer ${access_token}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof access_token === "string") {
|
||||
await fetchUtils.fetchJson(logout_api_url, options);
|
||||
localStorage.removeItem("access_token");
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
// called when the API returns an error
|
||||
checkError: ({ status }) => {
|
||||
@ -62,7 +77,7 @@ const authProvider = {
|
||||
checkAuth: () => {
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
console.log("checkAuth " + access_token);
|
||||
return typeof access_token == "string"
|
||||
return typeof access_token === "string"
|
||||
? Promise.resolve()
|
||||
: Promise.reject();
|
||||
},
|
||||
|
135
src/synapse/authProvider.test.js
Normal file
135
src/synapse/authProvider.test.js
Normal file
@ -0,0 +1,135 @@
|
||||
import authProvider from "./authProvider";
|
||||
|
||||
describe("authProvider", () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("should successfully login with username and password", async () => {
|
||||
fetch.once(
|
||||
JSON.stringify({
|
||||
home_server: "example.com",
|
||||
user_id: "@user:example.com",
|
||||
access_token: "foobar",
|
||||
device_id: "some_device",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await authProvider.login({
|
||||
base_url: "http://example.com",
|
||||
username: "@user:example.com",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toBeCalledWith(
|
||||
"http://example.com/_matrix/client/r0/login",
|
||||
{
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}',
|
||||
headers: new Headers({
|
||||
Accept: ["application/json"],
|
||||
"Content-Type": ["application/json"],
|
||||
}),
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
expect(localStorage.getItem("base_url")).toEqual("http://example.com");
|
||||
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
|
||||
expect(localStorage.getItem("access_token")).toEqual("foobar");
|
||||
expect(localStorage.getItem("device_id")).toEqual("some_device");
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully login with token", async () => {
|
||||
fetch.once(
|
||||
JSON.stringify({
|
||||
home_server: "example.com",
|
||||
user_id: "@user:example.com",
|
||||
access_token: "foobar",
|
||||
device_id: "some_device",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await authProvider.login({
|
||||
base_url: "https://example.com/",
|
||||
loginToken: "login_token",
|
||||
});
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toBeCalledWith(
|
||||
"https://example.com/_matrix/client/r0/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"],
|
||||
}),
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
expect(localStorage.getItem("base_url")).toEqual("https://example.com");
|
||||
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
|
||||
expect(localStorage.getItem("access_token")).toEqual("foobar");
|
||||
expect(localStorage.getItem("device_id")).toEqual("some_device");
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("should remove the access_token from localStorage", async () => {
|
||||
localStorage.setItem("base_url", "example.com");
|
||||
localStorage.setItem("access_token", "foo");
|
||||
fetch.mockResponse(JSON.stringify({}));
|
||||
|
||||
await authProvider.logout();
|
||||
|
||||
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
|
||||
headers: new Headers({
|
||||
Accept: ["application/json"],
|
||||
Authorization: ["Bearer foo"],
|
||||
}),
|
||||
method: "POST",
|
||||
user: { authenticated: true, token: "Bearer foo" },
|
||||
});
|
||||
expect(localStorage.getItem("access_token")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkError", () => {
|
||||
it("should resolve if error.status is not 401 or 403", async () => {
|
||||
await expect(
|
||||
authProvider.checkError({ status: 200 })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject if error.status is 401", async () => {
|
||||
await expect(
|
||||
authProvider.checkError({ status: 401 })
|
||||
).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject if error.status is 403", async () => {
|
||||
await expect(
|
||||
authProvider.checkError({ status: 403 })
|
||||
).rejects.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAuth", () => {
|
||||
it("should reject when not logged in", async () => {
|
||||
await expect(authProvider.checkAuth({})).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve when logged in", async () => {
|
||||
localStorage.setItem("access_token", "foobar");
|
||||
|
||||
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissions", () => {
|
||||
it("should do nothing", async () => {
|
||||
await expect(authProvider.getPermissions()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
@ -14,22 +14,46 @@ const jsonClient = (url, options = {}) => {
|
||||
return fetchUtils.fetchJson(url, options);
|
||||
};
|
||||
|
||||
const mxcUrlToHttp = mxcUrl => {
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||
var ret = re.exec(mxcUrl);
|
||||
console.log("mxcClient " + ret);
|
||||
if (ret == null) return null;
|
||||
const serverName = ret[1];
|
||||
const mediaId = ret[2];
|
||||
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
|
||||
};
|
||||
|
||||
const resourceMap = {
|
||||
users: {
|
||||
path: "/_synapse/admin/v2/users",
|
||||
map: u => ({
|
||||
...u,
|
||||
id: u.name,
|
||||
avatar_src: mxcUrlToHttp(u.avatar_url),
|
||||
is_guest: !!u.is_guest,
|
||||
admin: !!u.admin,
|
||||
deactivated: !!u.deactivated,
|
||||
// need timestamp in milliseconds
|
||||
creation_ts_ms: u.creation_ts * 1000,
|
||||
}),
|
||||
data: "users",
|
||||
total: (json, from, perPage) => {
|
||||
return json.next_token
|
||||
? parseInt(json.next_token, 10) + perPage
|
||||
: from + json.users.length;
|
||||
},
|
||||
total: json => json.total,
|
||||
create: data => ({
|
||||
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(
|
||||
data.id
|
||||
)}:${localStorage.getItem("home_server")}`,
|
||||
body: data,
|
||||
method: "PUT",
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(
|
||||
params.id
|
||||
)}`,
|
||||
body: { erase: true },
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
rooms: {
|
||||
path: "/_synapse/admin/v1/rooms",
|
||||
@ -38,11 +62,45 @@ const resourceMap = {
|
||||
id: r.room_id,
|
||||
alias: r.canonical_alias,
|
||||
members: r.joined_members,
|
||||
is_encrypted: !!r.encryption,
|
||||
federatable: !!r.federatable,
|
||||
public: !!r.public,
|
||||
}),
|
||||
data: "rooms",
|
||||
total: json => {
|
||||
return json.total_rooms;
|
||||
},
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
|
||||
body: { block: false },
|
||||
}),
|
||||
},
|
||||
reports: {
|
||||
path: "/_synapse/admin/v1/event_reports",
|
||||
map: er => ({
|
||||
...er,
|
||||
id: er.id,
|
||||
}),
|
||||
data: "event_reports",
|
||||
total: json => json.total,
|
||||
},
|
||||
devices: {
|
||||
map: d => ({
|
||||
...d,
|
||||
id: d.device_id,
|
||||
}),
|
||||
data: "devices",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
|
||||
params.previousData.user_id
|
||||
)}/devices/${params.id}`,
|
||||
}),
|
||||
},
|
||||
connections: {
|
||||
path: "/_synapse/admin/v1/whois",
|
||||
@ -52,83 +110,329 @@ const resourceMap = {
|
||||
}),
|
||||
data: "connections",
|
||||
},
|
||||
room_members: {
|
||||
map: m => ({
|
||||
id: m,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
|
||||
}),
|
||||
data: "members",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
},
|
||||
room_state: {
|
||||
map: rs => ({
|
||||
...rs,
|
||||
id: rs.event_id,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
|
||||
}),
|
||||
data: "state",
|
||||
total: json => {
|
||||
return json.state.length;
|
||||
},
|
||||
},
|
||||
pushers: {
|
||||
map: p => ({
|
||||
...p,
|
||||
id: p.pushkey,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
|
||||
}),
|
||||
data: "pushers",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
},
|
||||
joined_rooms: {
|
||||
map: jr => ({
|
||||
id: jr,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(
|
||||
id
|
||||
)}/joined_rooms`,
|
||||
}),
|
||||
data: "joined_rooms",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
},
|
||||
users_media: {
|
||||
map: um => ({
|
||||
...um,
|
||||
id: um.media_id,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
|
||||
}),
|
||||
data: "media",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
|
||||
"home_server"
|
||||
)}/${params.id}`,
|
||||
}),
|
||||
},
|
||||
delete_media: {
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
|
||||
"home_server"
|
||||
)}/delete?before_ts=${params.meta.before_ts}&size_gt=${
|
||||
params.meta.size_gt
|
||||
}&keep_profiles=${params.meta.keep_profiles}`,
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
protect_media: {
|
||||
map: pm => ({ id: pm.media_id }),
|
||||
create: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
quarantine_media: {
|
||||
map: qm => ({ id: qm.media_id }),
|
||||
create: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem(
|
||||
"home_server"
|
||||
)}/${params.media_id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem(
|
||||
"home_server"
|
||||
)}/${params.id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
servernotices: {
|
||||
map: n => ({ id: n.event_id }),
|
||||
create: data => ({
|
||||
endpoint: "/_synapse/admin/v1/send_server_notice",
|
||||
body: {
|
||||
user_id: data.id,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: data.body,
|
||||
},
|
||||
},
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
user_media_statistics: {
|
||||
path: "/_synapse/admin/v1/statistics/users/media",
|
||||
map: usms => ({
|
||||
...usms,
|
||||
id: usms.user_id,
|
||||
}),
|
||||
data: "users",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
},
|
||||
forward_extremities: {
|
||||
map: fe => ({
|
||||
...fe,
|
||||
id: fe.event_id,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
|
||||
}),
|
||||
data: "results",
|
||||
total: json => {
|
||||
return json.count;
|
||||
},
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
|
||||
}),
|
||||
},
|
||||
room_directory: {
|
||||
path: "/_matrix/client/r0/publicRooms",
|
||||
map: rd => ({
|
||||
...rd,
|
||||
id: rd.room_id,
|
||||
public: !!rd.public,
|
||||
guest_access: !!rd.guest_access,
|
||||
avatar_src: mxcUrlToHttp(rd.avatar_url),
|
||||
}),
|
||||
data: "chunk",
|
||||
total: json => {
|
||||
return json.total_room_count_estimate;
|
||||
},
|
||||
create: params => ({
|
||||
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
|
||||
body: { visibility: "public" },
|
||||
method: "PUT",
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
|
||||
body: { visibility: "private" },
|
||||
method: "PUT",
|
||||
}),
|
||||
},
|
||||
destinations: {
|
||||
path: "/_synapse/admin/v1/federation/destinations",
|
||||
map: dst => ({
|
||||
...dst,
|
||||
id: dst.destination,
|
||||
}),
|
||||
data: "destinations",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
destination_rooms: {
|
||||
map: dstroom => ({
|
||||
...dstroom,
|
||||
id: dstroom.room_id,
|
||||
}),
|
||||
reference: id => ({
|
||||
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
|
||||
}),
|
||||
data: "rooms",
|
||||
total: json => {
|
||||
return json.total;
|
||||
},
|
||||
},
|
||||
registration_tokens: {
|
||||
path: "/_synapse/admin/v1/registration_tokens",
|
||||
map: rt => ({
|
||||
...rt,
|
||||
id: rt.token,
|
||||
}),
|
||||
data: "registration_tokens",
|
||||
total: json => {
|
||||
return json.registration_tokens.length;
|
||||
},
|
||||
create: params => ({
|
||||
endpoint: "/_synapse/admin/v1/registration_tokens/new",
|
||||
body: params,
|
||||
method: "POST",
|
||||
}),
|
||||
delete: params => ({
|
||||
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
function filterNullValues(key, value) {
|
||||
// Filtering out null properties
|
||||
if (value === null) {
|
||||
// to reset user_type from user, it must be null
|
||||
if (value === null && key !== "user_type") {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getSearchOrder(order) {
|
||||
if (order === "DESC") {
|
||||
return "b";
|
||||
} else {
|
||||
return "f";
|
||||
}
|
||||
}
|
||||
|
||||
const dataProvider = {
|
||||
getList: (resource, params) => {
|
||||
getList: async (resource, params) => {
|
||||
console.log("getList " + resource);
|
||||
const { user_id, guests, deactivated } = params.filter;
|
||||
const {
|
||||
user_id,
|
||||
name,
|
||||
guests,
|
||||
deactivated,
|
||||
search_term,
|
||||
destination,
|
||||
valid,
|
||||
} = params.filter;
|
||||
const { page, perPage } = params.pagination;
|
||||
const { field, order } = params.sort;
|
||||
const from = (page - 1) * perPage;
|
||||
const query = {
|
||||
from: from,
|
||||
limit: perPage,
|
||||
user_id: user_id,
|
||||
search_term: search_term,
|
||||
name: name,
|
||||
destination: destination,
|
||||
guests: guests,
|
||||
deactivated: deactivated,
|
||||
valid: valid,
|
||||
order_by: field,
|
||||
dir: getSearchOrder(order),
|
||||
};
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
const url = `${homeserver_url}?${stringify(query)}`;
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const url = `${endpoint_url}?${stringify(query)}`;
|
||||
|
||||
return jsonClient(url).then(({ json }) => ({
|
||||
const { json } = await jsonClient(url);
|
||||
return {
|
||||
data: json[res.data].map(res.map),
|
||||
total: res.total(json, from, perPage),
|
||||
}));
|
||||
};
|
||||
},
|
||||
|
||||
getOne: (resource, params) => {
|
||||
getOne: async (resource, params) => {
|
||||
console.log("getOne " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return jsonClient(`${homeserver_url}/${params.id}`).then(({ json }) => ({
|
||||
data: res.map(json),
|
||||
}));
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const { json } = await jsonClient(
|
||||
`${endpoint_url}/${encodeURIComponent(params.id)}`
|
||||
);
|
||||
return { data: res.map(json) };
|
||||
},
|
||||
|
||||
getMany: (resource, params) => {
|
||||
getMany: async (resource, params) => {
|
||||
console.log("getMany " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return Promise.all(
|
||||
params.ids.map(id => jsonClient(`${homeserver_url}/${id}`))
|
||||
).then(responses => ({
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(id =>
|
||||
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
|
||||
)
|
||||
);
|
||||
return {
|
||||
data: responses.map(({ json }) => res.map(json)),
|
||||
}));
|
||||
total: responses.length,
|
||||
};
|
||||
},
|
||||
|
||||
getManyReference: (resource, params) => {
|
||||
// FIXME
|
||||
getManyReference: async (resource, params) => {
|
||||
console.log("getManyReference " + resource);
|
||||
const { page, perPage } = params.pagination;
|
||||
const { field, order } = params.sort;
|
||||
const from = (page - 1) * perPage;
|
||||
const query = {
|
||||
sort: JSON.stringify([field, order]),
|
||||
range: JSON.stringify([(page - 1) * perPage, page * perPage - 1]),
|
||||
filter: JSON.stringify({
|
||||
...params.filter,
|
||||
[params.target]: params.id,
|
||||
}),
|
||||
from: from,
|
||||
limit: perPage,
|
||||
order_by: field,
|
||||
dir: getSearchOrder(order),
|
||||
};
|
||||
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
@ -136,98 +440,151 @@ const dataProvider = {
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
const url = `${homeserver_url}?${stringify(query)}`;
|
||||
const ref = res["reference"](params.id);
|
||||
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
|
||||
|
||||
return jsonClient(url).then(({ headers, json }) => ({
|
||||
data: json,
|
||||
total: parseInt(headers.get("content-range").split("/").pop(), 10),
|
||||
}));
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return {
|
||||
data: json[res.data].map(res.map),
|
||||
total: res.total(json, from, perPage),
|
||||
};
|
||||
},
|
||||
|
||||
update: (resource, params) => {
|
||||
update: async (resource, params) => {
|
||||
console.log("update " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return jsonClient(`${homeserver_url}/${params.data.id}`, {
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const { json } = await jsonClient(
|
||||
`${endpoint_url}/${encodeURIComponent(params.id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params.data, filterNullValues),
|
||||
}).then(({ json }) => ({
|
||||
data: res.map(json),
|
||||
}));
|
||||
}
|
||||
);
|
||||
return { data: res.map(json) };
|
||||
},
|
||||
|
||||
updateMany: (resource, params) => {
|
||||
updateMany: async (resource, params) => {
|
||||
console.log("updateMany " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return Promise.all(
|
||||
params.ids.map(id => jsonClient(`${homeserver_url}/${id}`), {
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(
|
||||
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params.data, filterNullValues),
|
||||
})
|
||||
).then(responses => ({
|
||||
data: responses.map(({ json }) => json),
|
||||
}));
|
||||
}
|
||||
)
|
||||
);
|
||||
return { data: responses.map(({ json }) => json) };
|
||||
},
|
||||
|
||||
create: (resource, params) => {
|
||||
create: async (resource, params) => {
|
||||
console.log("create " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
if (!("create" in res)) return Promise.reject();
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return jsonClient(`${homeserver_url}/${params.data.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params.data, filterNullValues),
|
||||
}).then(({ json }) => ({
|
||||
data: res.map(json),
|
||||
}));
|
||||
const create = res["create"](params.data);
|
||||
const endpoint_url = homeserver + create.endpoint;
|
||||
const { json } = await jsonClient(endpoint_url, {
|
||||
method: create.method,
|
||||
body: JSON.stringify(create.body, filterNullValues),
|
||||
});
|
||||
return { data: res.map(json) };
|
||||
},
|
||||
|
||||
delete: (resource, params) => {
|
||||
createMany: async (resource, params) => {
|
||||
console.log("createMany " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
if (!("create" in res)) return Promise.reject();
|
||||
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(id => {
|
||||
params.data.id = id;
|
||||
const cre = res["create"](params.data);
|
||||
const endpoint_url = homeserver + cre.endpoint;
|
||||
return jsonClient(endpoint_url, {
|
||||
method: cre.method,
|
||||
body: JSON.stringify(cre.body, filterNullValues),
|
||||
});
|
||||
})
|
||||
);
|
||||
return { data: responses.map(({ json }) => json) };
|
||||
},
|
||||
|
||||
delete: async (resource, params) => {
|
||||
console.log("delete " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return jsonClient(`${homeserver_url}/${params.id}`, {
|
||||
if ("delete" in res) {
|
||||
const del = res["delete"](params);
|
||||
const endpoint_url = homeserver + del.endpoint;
|
||||
const { json } = await jsonClient(endpoint_url, {
|
||||
method: "method" in del ? del.method : "DELETE",
|
||||
body: "body" in del ? JSON.stringify(del.body) : null,
|
||||
});
|
||||
return { data: json };
|
||||
} else {
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const { json } = await jsonClient(`${endpoint_url}/${params.id}`, {
|
||||
method: "DELETE",
|
||||
}).then(({ json }) => ({
|
||||
data: json,
|
||||
}));
|
||||
body: JSON.stringify(params.previousData, filterNullValues),
|
||||
});
|
||||
return { data: json };
|
||||
}
|
||||
},
|
||||
|
||||
deleteMany: (resource, params) => {
|
||||
deleteMany: async (resource, params) => {
|
||||
console.log("deleteMany " + resource);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const homeserver_url = homeserver + res.path;
|
||||
return Promise.all(
|
||||
if ("delete" in res) {
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(id => {
|
||||
const del = res["delete"]({ ...params, id: id });
|
||||
const endpoint_url = homeserver + del.endpoint;
|
||||
return jsonClient(endpoint_url, {
|
||||
method: "method" in del ? del.method : "DELETE",
|
||||
body: "body" in del ? JSON.stringify(del.body) : null,
|
||||
});
|
||||
})
|
||||
);
|
||||
return {
|
||||
data: responses.map(({ json }) => json),
|
||||
};
|
||||
} else {
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const responses = await Promise.all(
|
||||
params.ids.map(id =>
|
||||
jsonClient(`${homeserver_url}/${id}`, {
|
||||
jsonClient(`${endpoint_url}/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(params.data, filterNullValues),
|
||||
}).then(responses => ({
|
||||
data: responses.map(({ json }) => json),
|
||||
}))
|
||||
})
|
||||
)
|
||||
);
|
||||
return { data: responses.map(({ json }) => json) };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
78
src/synapse/dataProvider.test.js
Normal file
78
src/synapse/dataProvider.test.js
Normal file
@ -0,0 +1,78 @@
|
||||
import dataProvider from "./dataProvider";
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
describe("dataProvider", () => {
|
||||
localStorage.setItem("base_url", "http://localhost");
|
||||
localStorage.setItem("access_token", "access_token");
|
||||
|
||||
it("fetches all users", async () => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
users: [
|
||||
{
|
||||
name: "user_id1",
|
||||
password_hash: "password_hash1",
|
||||
is_guest: 0,
|
||||
admin: 0,
|
||||
user_type: null,
|
||||
deactivated: 0,
|
||||
displayname: "User One",
|
||||
},
|
||||
{
|
||||
name: "user_id2",
|
||||
password_hash: "password_hash2",
|
||||
is_guest: 0,
|
||||
admin: 1,
|
||||
user_type: null,
|
||||
deactivated: 0,
|
||||
displayname: "User Two",
|
||||
},
|
||||
],
|
||||
next_token: "100",
|
||||
total: 200,
|
||||
})
|
||||
);
|
||||
|
||||
const users = await dataProvider.getList("users", {
|
||||
pagination: { page: 1, perPage: 5 },
|
||||
sort: { field: "title", order: "ASC" },
|
||||
filter: { author_id: 12 },
|
||||
});
|
||||
|
||||
expect(users["data"][0]["id"]).toEqual("user_id1");
|
||||
expect(users["total"]).toEqual(200);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches one user", async () => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
name: "user_id1",
|
||||
password: "user_password",
|
||||
displayname: "User",
|
||||
threepids: [
|
||||
{
|
||||
medium: "email",
|
||||
address: "user@mail_1.com",
|
||||
},
|
||||
{
|
||||
medium: "email",
|
||||
address: "user@mail_2.com",
|
||||
},
|
||||
],
|
||||
avatar_url: "mxc://localhost/user1",
|
||||
admin: false,
|
||||
deactivated: false,
|
||||
})
|
||||
);
|
||||
|
||||
const user = await dataProvider.getOne("users", { id: "user_id1" });
|
||||
|
||||
expect(user["data"]["id"]).toEqual("user_id1");
|
||||
expect(user["data"]["displayname"]).toEqual("User");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
55
src/synapse/synapse.js
Normal file
55
src/synapse/synapse.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { fetchUtils } from "react-admin";
|
||||
|
||||
export const splitMxid = mxid => {
|
||||
const re =
|
||||
/^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
|
||||
return re.exec(mxid)?.groups;
|
||||
};
|
||||
|
||||
export const isValidBaseUrl = baseUrl =>
|
||||
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
|
||||
|
||||
/**
|
||||
* Resolve the homeserver URL using the well-known lookup
|
||||
* @param domain the domain part of an MXID
|
||||
* @returns homeserver base URL
|
||||
*/
|
||||
export const getWellKnownUrl = async domain => {
|
||||
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
|
||||
try {
|
||||
const json = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
|
||||
return json["m.homeserver"].base_url;
|
||||
} catch {
|
||||
// if there is no .well-known entry, return the domain itself
|
||||
return `https://${domain}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get synapse server version
|
||||
* @param base_url the base URL of the homeserver
|
||||
* @returns server version
|
||||
*/
|
||||
export const getServerVersion = async baseUrl => {
|
||||
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
|
||||
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
|
||||
return response.json.server_version;
|
||||
};
|
||||
|
||||
/** Get supported Matrix features */
|
||||
export const getSupportedFeatures = async baseUrl => {
|
||||
const versionUrl = `${baseUrl}/_matrix/client/versions`;
|
||||
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
|
||||
return response.json;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get supported login flows
|
||||
* @param baseUrl the base URL of the homeserver
|
||||
* @returns array of supported login flows
|
||||
*/
|
||||
export const getSupportedLoginFlows = async baseUrl => {
|
||||
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
|
||||
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
|
||||
return response.json.flows;
|
||||
};
|
31
src/synapse/synapse.test.js
Normal file
31
src/synapse/synapse.test.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { isValidBaseUrl, splitMxid } from "./synapse";
|
||||
|
||||
describe("splitMxid", () => {
|
||||
it("splits valid MXIDs", () =>
|
||||
expect(splitMxid("@name:domain.tld")).toEqual({
|
||||
name: "name",
|
||||
domain: "domain.tld",
|
||||
}));
|
||||
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
|
||||
});
|
||||
|
||||
describe("isValidBaseUrl", () => {
|
||||
it("accepts a http URL", () =>
|
||||
expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
|
||||
it("accepts a https URL", () =>
|
||||
expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
|
||||
it("accepts a valid URL with port", () =>
|
||||
expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
|
||||
it("rejects undefined base URLs", () =>
|
||||
expect(isValidBaseUrl(undefined)).toBeFalsy());
|
||||
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
|
||||
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
|
||||
it("rejects non-string base URLs", () =>
|
||||
expect(isValidBaseUrl({})).toBeFalsy());
|
||||
it("rejects base URLs without protocol", () =>
|
||||
expect(isValidBaseUrl("foo.bar")).toBeFalsy());
|
||||
it("rejects base URLs with path", () =>
|
||||
expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
|
||||
it("rejects invalid base URLs", () =>
|
||||
expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
|
||||
});
|
Loading…
Reference in New Issue
Block a user