Compare commits
297 Commits
master
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c596d38d7a | ||
![]() |
c643bdcfce | ||
![]() |
630286a781 | ||
![]() |
71e90c12a7 | ||
![]() |
db6594f11f | ||
![]() |
7844987fe0 | ||
![]() |
9f2293f9c4 | ||
![]() |
fd4bf7ce5c | ||
![]() |
0dc969ce2c | ||
![]() |
f5455ef667 | ||
![]() |
edb8028162 | ||
![]() |
f415d637e9 | ||
![]() |
2c9103c769 | ||
![]() |
1c6f91d3a9 | ||
![]() |
05826e2ae1 | ||
![]() |
f6ebd71ac2 | ||
![]() |
49d0157c81 | ||
![]() |
ccfc5e4478 | ||
![]() |
66953dba3d | ||
![]() |
f4df8c070c | ||
![]() |
c3fa4daddb | ||
![]() |
b6782c64f7 | ||
![]() |
8a65621b35 | ||
![]() |
b8b0d46070 | ||
![]() |
2f73970e47 | ||
![]() |
ceacddf9c6 | ||
![]() |
4e89ad5f56 | ||
![]() |
1d4b4f97da | ||
![]() |
ac36a93203 | ||
![]() |
1dd0b242e2 | ||
![]() |
608d9fa9d8 | ||
![]() |
f5841c6a30 | ||
![]() |
266b13ce0b | ||
![]() |
1be9171b93 | ||
![]() |
037c2acee2 | ||
![]() |
38a0c9a9fc | ||
![]() |
8703cff7bf | ||
![]() |
687e3364b0 | ||
![]() |
149f5737dd | ||
![]() |
c2867e44c6 | ||
![]() |
f58562cd5b | ||
![]() |
92845a8640 | ||
![]() |
4fd18007de | ||
![]() |
005be8eec5 | ||
![]() |
402457640c | ||
![]() |
c3dd0facd2 | ||
![]() |
2fa5600d24 | ||
![]() |
c35416ec13 | ||
![]() |
9e6e31a728 | ||
![]() |
d7677af085 | ||
![]() |
dc0ba15828 | ||
![]() |
6527565a18 | ||
![]() |
c0a4004fd3 | ||
![]() |
97384d1ebb | ||
![]() |
41768eb157 | ||
![]() |
0c89493b58 | ||
![]() |
d5d1be794d | ||
![]() |
b2dc623448 | ||
![]() |
fa90a82745 | ||
![]() |
b55532e93e | ||
![]() |
880270bfe6 | ||
![]() |
7c21692a1e | ||
![]() |
3b69e78bb8 | ||
![]() |
a6caa0714e | ||
![]() |
83473dd3fa | ||
![]() |
aad90760a5 | ||
![]() |
cb853bf342 | ||
![]() |
8d45ae2886 | ||
![]() |
2e151cf198 | ||
![]() |
52c17bc5ca | ||
![]() |
d79d825ab4 | ||
![]() |
28b80d7fa2 | ||
![]() |
9735ffbc19 | ||
![]() |
f4d0e19d21 | ||
![]() |
14d1c904c0 | ||
![]() |
392fec3186 | ||
![]() |
ea0c7a73fd | ||
![]() |
21c2fdb5a6 | ||
![]() |
0a55fc6182 | ||
![]() |
64c4bd760b | ||
![]() |
e0c8fef365 | ||
![]() |
29b757ac75 | ||
![]() |
6eb0aaea88 | ||
![]() |
b42a5c3b69 | ||
![]() |
66447ac025 | ||
![]() |
747818af6a | ||
![]() |
a86fa93608 | ||
![]() |
f948318919 | ||
![]() |
5fea8de023 | ||
![]() |
20417a67b9 | ||
![]() |
cfd8238edc | ||
![]() |
eae00a6ccd | ||
![]() |
3731410486 | ||
![]() |
099e349a90 | ||
![]() |
de125d0d66 | ||
![]() |
eb8e8f80a9 | ||
![]() |
1229266546 | ||
![]() |
6620942928 | ||
![]() |
998d3817c6 | ||
![]() |
abca842eb9 | ||
![]() |
cb1314ab1d | ||
![]() |
dae6872fe8 | ||
![]() |
61b91adf25 | ||
![]() |
5f7f587477 | ||
![]() |
75b9aeea01 | ||
![]() |
fb71b5ed08 | ||
![]() |
2e8e5e73bc | ||
![]() |
befcd15298 | ||
![]() |
86d0fd04e6 | ||
![]() |
c28ed94ca0 | ||
![]() |
d139ab4a7d | ||
![]() |
6575a05a8a | ||
![]() |
3f2c3f2c0c | ||
![]() |
2c5ee7da10 | ||
![]() |
03bf4a5c5d | ||
![]() |
3fce37b735 | ||
![]() |
6c6ec227fa | ||
![]() |
128fea726e | ||
![]() |
97c44f85ef | ||
![]() |
ca2decda3a | ||
![]() |
e394a7e186 | ||
![]() |
c182f600f8 | ||
![]() |
915e3564f8 | ||
![]() |
1570ca5485 | ||
![]() |
b94b782547 | ||
![]() |
90328a5b19 | ||
![]() |
b42eb07a2f | ||
![]() |
131bfaaece | ||
![]() |
b759d5d5fb | ||
![]() |
003e9da792 | ||
![]() |
908271060b | ||
![]() |
08cd427084 | ||
![]() |
73a1d952b1 | ||
![]() |
1d5511421d | ||
![]() |
b925c63171 | ||
![]() |
6faebaf9df | ||
![]() |
c698f57395 | ||
![]() |
9adc13e722 | ||
![]() |
a04b24a5d5 | ||
![]() |
cd1ca7c039 | ||
![]() |
86b4987b7f | ||
![]() |
a7cf647669 | ||
![]() |
80d40d2fb5 | ||
![]() |
b19e961a35 | ||
![]() |
08f5f8ebd2 | ||
![]() |
1edf196049 | ||
![]() |
1f66b4d14a | ||
![]() |
cec5b0af9a | ||
![]() |
2b0e1e7c0e | ||
![]() |
a613a88232 | ||
![]() |
7afce71bef | ||
![]() |
fe4ba22a03 | ||
![]() |
eb35d9e122 | ||
![]() |
fa79fecc9d | ||
![]() |
61366b3792 | ||
![]() |
b55033d983 | ||
![]() |
b1f42988c8 | ||
![]() |
ef05b366c3 | ||
![]() |
b3d52e7d23 | ||
![]() |
d1c4250b46 | ||
![]() |
8d44077b24 | ||
![]() |
44e8b82412 | ||
![]() |
791ae2c869 | ||
![]() |
ee3753466a | ||
![]() |
b7dc703157 | ||
![]() |
2eca0dcc33 | ||
![]() |
d2219c1667 | ||
![]() |
132ea6f97f | ||
![]() |
28ef08de03 | ||
![]() |
865e53388e | ||
![]() |
3a105bb8c7 | ||
![]() |
edcda7a202 | ||
![]() |
edd69273e2 | ||
![]() |
444bfacbd9 | ||
![]() |
970e0a550f | ||
![]() |
b3ef68d66e | ||
![]() |
31382a42ee | ||
![]() |
1a7748d1ef | ||
![]() |
039b28cc5c | ||
![]() |
57eae3edb3 | ||
![]() |
dadc9416c0 | ||
![]() |
eab2342114 | ||
![]() |
9cf2f83936 | ||
![]() |
d823856873 | ||
![]() |
9b96c7cec8 | ||
![]() |
f211aba873 | ||
![]() |
c0fc2d8937 | ||
![]() |
a88b397748 | ||
![]() |
abc922c956 | ||
![]() |
4f2cd38344 | ||
![]() |
ca71038874 | ||
![]() |
be867b6b0d | ||
![]() |
f2f540b429 | ||
![]() |
7feec4ba07 | ||
![]() |
1d5fef1e53 | ||
![]() |
9c40efde17 | ||
![]() |
53dff66978 | ||
![]() |
3a595247e8 | ||
![]() |
33f5f60e31 | ||
![]() |
9dd2ea57c9 | ||
![]() |
fae7a696de | ||
![]() |
49e8b2d0f5 | ||
![]() |
281d908d3f | ||
![]() |
bacc42fe9c | ||
![]() |
1c26a28ca9 | ||
![]() |
d3a04cd132 | ||
![]() |
e6060a23ac | ||
![]() |
4b7fbf483a | ||
![]() |
bc3c30da92 | ||
![]() |
1896f770d1 | ||
![]() |
99d0b9ad72 | ||
![]() |
944afb9056 | ||
![]() |
23f5a24803 | ||
![]() |
60ae00ac14 | ||
![]() |
26862fa708 | ||
![]() |
853d14c1ce | ||
![]() |
11a5cac709 | ||
![]() |
0d021021df | ||
![]() |
19302466ef | ||
![]() |
0594259ae4 | ||
![]() |
ba485bbb18 | ||
![]() |
f5d6f24b30 | ||
![]() |
a42efe7eda | ||
![]() |
31d3712dbb | ||
![]() |
a15dad4a31 | ||
![]() |
9062a6afb1 | ||
![]() |
a79c3597d6 | ||
![]() |
470f1b5455 | ||
![]() |
46cc0e2fda | ||
![]() |
f3080e9468 | ||
![]() |
f8fe1166e2 | ||
![]() |
02bee92806 | ||
![]() |
b7f8e03894 | ||
![]() |
59bb4b4183 | ||
![]() |
bb53d53692 | ||
![]() |
52a2f1c936 | ||
![]() |
e328380c77 | ||
![]() |
a277ded227 | ||
![]() |
48d933e028 | ||
![]() |
24cf0a60bf | ||
![]() |
d5113aad72 | ||
![]() |
6957cb1f7d | ||
![]() |
66c706532a | ||
![]() |
332e98a095 | ||
![]() |
f639a1c9ff | ||
![]() |
8282bb0926 | ||
![]() |
264d2ff59d | ||
![]() |
90a6c7d8c2 | ||
![]() |
7de9166648 | ||
![]() |
0bf3440fc8 | ||
![]() |
cceae77529 | ||
![]() |
1474b46ff0 | ||
![]() |
01e3947b22 | ||
![]() |
e093bd8625 | ||
![]() |
390aab5ce7 | ||
![]() |
fb1a04971b | ||
![]() |
eff17a0929 | ||
![]() |
8eaaaa50ec | ||
![]() |
85b305d117 | ||
![]() |
4cbea6ffb6 | ||
![]() |
1967546ae4 | ||
![]() |
c9a3294852 | ||
![]() |
f1839387e2 | ||
![]() |
c8246a1d19 | ||
![]() |
3c6259cc88 | ||
![]() |
317df5af0f | ||
![]() |
2142770a5b | ||
![]() |
51297b49fc | ||
![]() |
311cc2a1f4 | ||
![]() |
6bc760a6fa | ||
![]() |
50c96cfd77 | ||
![]() |
7747dc7f28 | ||
![]() |
678867e981 | ||
![]() |
4a4fae104e | ||
![]() |
265b5157af | ||
![]() |
b5b593945d | ||
![]() |
15c8a30c92 | ||
![]() |
40c6eb0b95 | ||
![]() |
4c4e3a07f6 | ||
![]() |
35322fa01d | ||
![]() |
88e88fb6b9 | ||
![]() |
7016e5a349 | ||
![]() |
c7e275d4ec | ||
![]() |
b1d3340fce | ||
![]() |
2ae5930578 | ||
![]() |
9dd6940383 | ||
![]() |
e2194f5c49 | ||
![]() |
e6dcbcb052 | ||
![]() |
5acf3042c3 | ||
![]() |
7286abaaae | ||
![]() |
0d5e95fa7c | ||
![]() |
c4b54c40fb | ||
![]() |
ec0b980e06 | ||
![]() |
94ccd3ad36 | ||
![]() |
49d67f9130 | ||
![]() |
2bb846734e | ||
![]() |
056d9c6b4c |
@@ -1,13 +0,0 @@
|
||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
||||
tests/
|
||||
build/
|
||||
dist/
|
||||
lib/
|
||||
node_modules/
|
||||
electron_app/
|
||||
karma-reports/
|
||||
.pnp.cjs
|
||||
.pnp.loader.mjs
|
||||
.idea/
|
||||
.tmp/
|
||||
config.json*
|
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
liberapay: etkecc
|
7
.github/dependabot.yml
vendored
@@ -4,17 +4,16 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Major updates for react-admin have breaking changes
|
||||
- dependency-name: "react-admin"
|
||||
update-types: ["version-update:semver-major"]
|
||||
open-pull-requests-limit: 30
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 30
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 30
|
||||
|
23
.github/workflows/build-test.yml
vendored
@@ -1,23 +0,0 @@
|
||||
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 checks
|
||||
run: yarn lint
|
||||
- name: Run tests
|
||||
run: yarn test
|
63
.github/workflows/docker-release.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Create docker image(s) and push to docker hub and ghcr.io
|
||||
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
|
||||
|
||||
on:
|
||||
push:
|
||||
# Sequence of patterns matched against refs/heads
|
||||
# prettier-ignore
|
||||
branches:
|
||||
# Push events on master branch
|
||||
- master
|
||||
# Sequence of patterns matched against refs/tags
|
||||
tags:
|
||||
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Push Docker image to multiple registries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
awesometechnologies/synapse-admin
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and Push Tag
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
29
.github/workflows/edge_ghpage.yml
vendored
@@ -1,29 +0,0 @@
|
||||
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-depth: 100
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Install and Build 🔧
|
||||
run: |
|
||||
yarn install --immutable
|
||||
yarn build --base=/synapse-admin
|
||||
|
||||
- name: Deploy 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@v4.6.3
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: dist
|
30
.github/workflows/github-release.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Create release tarball and attach to tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: yarn install --immutable
|
||||
- run: yarn build
|
||||
- run: |
|
||||
version=`git describe --dirty --tags || echo unknown`
|
||||
cp -r dist synapse-admin-$version
|
||||
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
|
||||
- uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191
|
||||
with:
|
||||
files: dist/*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
115
.github/workflows/workflow.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
env:
|
||||
bunny_version: v0.1.0
|
||||
base_path: ./
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --network-timeout=300000
|
||||
- name: Build
|
||||
run: yarn build --base=${{ env.base_path }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: dist/
|
||||
name: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
overwrite: true
|
||||
include-hidden-files: true
|
||||
|
||||
docker:
|
||||
name: Docker
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to ghcr.io
|
||||
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: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
registry.etke.cc/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
cdn:
|
||||
name: CDN
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Upload
|
||||
run: |
|
||||
wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz
|
||||
tar -xzf bunny-upload.tar.gz
|
||||
echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml
|
||||
./bunny-upload -c bunny-config.yaml
|
||||
|
||||
github-release:
|
||||
name: Github Release
|
||||
needs: build
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mv dist synapse-admin
|
||||
tar chvzf synapse-admin.tar.gz synapse-admin
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: synapse-admin.tar.gz
|
||||
generate_release_notes: true
|
||||
make_latest: "true"
|
||||
draft: false
|
||||
prerelease: false
|
3
.gitignore
vendored
@@ -191,3 +191,6 @@ sketch
|
||||
# .pnp.*
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
||||
|
||||
/testdata/synapse.data
|
||||
/testdata/postgres.data
|
||||
|
2
.vscode/settings.json
vendored
@@ -5,6 +5,6 @@
|
||||
},
|
||||
"eslint.nodePath": ".yarn/sdks",
|
||||
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
||||
"typescript.tsdk": ".yarn/sdks/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
|
5
.watchmanconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ignore_dirs": [
|
||||
"testdata"
|
||||
]
|
||||
}
|
@@ -1 +0,0 @@
|
||||
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
27
Dockerfile
@@ -1,26 +1,5 @@
|
||||
# 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
|
||||
# Base path for synapse admin
|
||||
ARG BASE_PATH=./
|
||||
FROM ghcr.io/static-web-server/static-web-server:2
|
||||
|
||||
WORKDIR /src
|
||||
ENV SERVER_ROOT=/app
|
||||
|
||||
# Copy .yarn directory to the working directory (must be on a separate line!)
|
||||
# Use https://docs.docker.com/engine/reference/builder/#copy---parents when available
|
||||
COPY .yarn .yarn
|
||||
COPY package.json .yarnrc.yml yarn.lock ./
|
||||
|
||||
# Disable telemetry and install packages
|
||||
RUN yarn config set enableTelemetry 0 && yarn install --immutable --network-timeout=300000
|
||||
|
||||
COPY . /src
|
||||
RUN yarn build --base=$BASE_PATH
|
||||
|
||||
# App
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html \
|
||||
&& ln -s /app /usr/share/nginx/html
|
||||
COPY ./dist /app
|
||||
|
251
README.md
@@ -1,19 +1,197 @@
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||
[](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
|
||||
[](https://awesome-technologies.github.io/synapse-admin/)
|
||||
[](https://hub.docker.com/r/awesometechnologies/synapse-admin)
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/releases)
|
||||
<p align="center">
|
||||
<img alt="Synapse Admin Logo" src="./public/images/logo.webp" height="140" />
|
||||
<h3 align="center">
|
||||
Synapse Admin<br>
|
||||
<a href="https://matrix.to/#/#synapse-admin:etke.cc">
|
||||
<img alt="Community room" src="https://img.shields.io/badge/room-community_room-green?logo=matrix&label=%23synapse-admin%3Aetke.cc">
|
||||
</a><br>
|
||||
<a href="./LICENSE">
|
||||
<img alt="License" src="https://img.shields.io/github/license/etkecc/synapse-admin">
|
||||
</a>
|
||||
</h3>
|
||||
<p align="center">Feature-packed and visually customizable: A better way to manage your Synapse homeserver.</p>
|
||||
</p>
|
||||
|
||||
# Synapse admin ui
|
||||
---
|
||||
|
||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||

|
||||

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

|
||||
|
||||
## Development
|
||||
|
||||
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
||||
|
20
docker-compose-dev.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
entrypoint: python
|
||||
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- ./testdata/synapse:/config
|
||||
- ./testdata/synapse.data:/media-store
|
||||
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
volumes:
|
||||
- ./testdata/postgres.data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: synapse
|
||||
POSTGRES_PASSWORD: synapse
|
||||
POSTGRES_DB: synapse
|
||||
POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"
|
@@ -2,13 +2,13 @@ services:
|
||||
synapse-admin:
|
||||
container_name: synapse-admin
|
||||
hostname: synapse-admin
|
||||
image: awesometechnologies/synapse-admin:latest
|
||||
image: ghcr.io/etkecc/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
|
||||
# context: https://github.com/etkecc/synapse-admin.git
|
||||
|
||||
# args:
|
||||
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
|
39
docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Documentation
|
||||
|
||||
Synapse Admin documentation is under construction right now, so PRs are greatly appreciated!
|
||||
|
||||
Table of contents:
|
||||
<!-- vim-markdown-toc GFM -->
|
||||
|
||||
* [Configuration](#configuration)
|
||||
* [Features](#features)
|
||||
* [Deployment](#deployment)
|
||||
|
||||
<!-- vim-markdown-toc -->
|
||||
|
||||
## Configuration
|
||||
|
||||
[Full configuration documentation](./config.md)
|
||||
|
||||
Specific configuration options:
|
||||
|
||||
* [Restricting available homeserver](./restrict-hs.md)
|
||||
* [System / Appservice-managed Users](./system-users.md)
|
||||
* [Custom Menu Items](./custom-menu.md)
|
||||
|
||||
## Features
|
||||
|
||||
* [User Badges](./user-badges.md)
|
||||
* [Prefilling the Login Form](./prefill-login-form.md)
|
||||
|
||||
for [etke.cc](https://etke.cc) customers only:
|
||||
|
||||
> **Note:** The following features are only available for etke.cc customers. Due to specifics of the implementation,
|
||||
they are not available for any other Synapse Admin deployments.
|
||||
|
||||
* [Server Status icon](../src/components/etke.cc/README.md#server-status-icon)
|
||||
* [Server Status page](../src/components/etke.cc/README.md#server-status-page)
|
||||
|
||||
## Deployment
|
||||
|
||||
* [Serving Synapse Admin behind a reverse proxy](./reverse-proxy.md)
|
94
docs/config.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Configuration
|
||||
|
||||
Synapse Admin could be configured using the following ways (both are optional, and both could be used together):
|
||||
|
||||
* By providing the `config.json` file alongside with the Synapse Admin deployment, example: [admin.etke.cc/config.json](https://admin.etke.cc/config.json)
|
||||
* By providing configuration under the `cc.etke.synapse-admin` key in the `/.well-known/matrix/client` file, example:
|
||||
[demo.etke.host/.well-known/matrix/client](https://demo.etke.host/.well-known/matrix/client)
|
||||
|
||||
In case you are an [etke.cc](https://etke.cc) customer,
|
||||
or use [spantaleev/matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy),
|
||||
or [etkecc/ansible](https://github.com/etkecc/ansible),
|
||||
configuration will be automatically added to the `/.well-known/matrix/client` file.
|
||||
|
||||
**Why `/.well-known/matrix/client`?**
|
||||
|
||||
Because any instance of Synapse Admin will automatically pick up the configuration from the homeserver.
|
||||
Common use case when you have a Synapse server running, but don't want (or can't) deploy Synapse Admin alongside with it.
|
||||
In this case, you could provide the configuration in the `/.well-known/matrix/client` file,
|
||||
and any Synapse Admin instance (e.g., [admin.etke.cc](https://admin.etke.cc) will pick it up.
|
||||
|
||||
Another common case is when you have multiple Synapse servers running and want to use a single Synapse Admin instance to manage them all.
|
||||
In this case, you could provide the configuration in the `/.well-known/matrix/client` file for each of the servers.
|
||||
|
||||
## Configuration options
|
||||
|
||||
* `restrictBaseUrl` - restrictBaseUrl restricts the Synapse Admin instance to work only with specific homeserver(-s).
|
||||
It accepts both a string and an array of strings.
|
||||
The homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
|
||||
Example: `https://matrix.example.com` or `https://synapse.example.net`
|
||||
[More details](restrict-hs.md)
|
||||
* `asManagedUsers` - protect system user accounts managed by appservices (such as bridges) / system (such as bots) from accidental changes.
|
||||
By defining a list of MXID regex patterns, you can protect these accounts from accidental changes.
|
||||
Example: `^@baibot:example\\.com$`, `^@slackbot:example\\.com$`, `^@slack_[a-zA-Z0-9\\-]+:example\\.com$`, `^@telegrambot:example\\.com$`, `^@telegram_[a-zA-Z0-9]+:example\\.com$`
|
||||
[More details](system-users.md)
|
||||
* `menu` - add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
|
||||
Each `menu` item can contain the following fields:
|
||||
* `label` (required): The text to display in the menu.
|
||||
* `icon` (optional): The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a default icon will be used.
|
||||
* `url` (required): The URL to navigate to when the menu item is clicked.
|
||||
[More details](custom-menu.md)
|
||||
|
||||
## Examples
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"restrictBaseUrl": [
|
||||
"https://matrix.example.com",
|
||||
"https://synapse.example.net"
|
||||
],
|
||||
"asManagedUsers": [
|
||||
"^@baibot:example\\.com$",
|
||||
"^@slackbot:example\\.com$",
|
||||
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||
"^@telegrambot:example\\.com$",
|
||||
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||
],
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `/.well-known/matrix/client`
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"restrictBaseUrl": [
|
||||
"https://matrix.example.com",
|
||||
"https://synapse.example.net"
|
||||
],
|
||||
"asManagedUsers": [
|
||||
"^@baibot:example\\.com$",
|
||||
"^@slackbot:example\\.com$",
|
||||
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||
"^@telegrambot:example\\.com$",
|
||||
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||
],
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
47
docs/custom-menu.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Custom Menu Items
|
||||
|
||||
You can add custom menu items to the main menu (sidebar) by providing a `menu` array in the config.
|
||||
This is useful for adding links to external sites or other pages in your documentation, like a support page or internal wiki.
|
||||
|
||||
## Configuration
|
||||
|
||||
The examples below contain the configuration settings to add a link to the [Synapse Admin issues](https://github.com/etke.cc/synapse-admin/issues).
|
||||
|
||||
Each `menu` item can contain the following fields:
|
||||
|
||||
* `label` (required): The text to display in the menu.
|
||||
* `icon` (optional): The icon to display next to the label, one of the [src/utils/icons.ts](../src/utils/icons.ts) icons, otherwise a
|
||||
default icon will be used.
|
||||
* `url` (required): The URL to navigate to when the menu item is clicked.
|
||||
|
||||
[Configuration options](config.md)
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `/.well-known/matrix/client`
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
48
docs/prefill-login-form.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Prefilling the Login Form
|
||||
|
||||
In some cases you may wish to prefill/preset the login form fields when sharing a link to a Synapse Admin instance.
|
||||
This can be done by adding the following query parameters to the URL:
|
||||
|
||||
* `username` - The username to prefill in the username field.
|
||||
* `server` - The server to prefill in the homeserver url field.
|
||||
|
||||
The following query params will work only if the Synapse Admin is loaded from `localhost` or `127.0.0.1`:
|
||||
|
||||
* `password` - The password to prefill in the password field (credentials auth). **NEVER** use this in production.
|
||||
* `accessToken` - The access token to prefill in the access token field (access token auth). **NEVER** use this in production.
|
||||
|
||||
> **WARNING**: Never use the `password` or `accessToken` query parameters in production as they can be easily extracted
|
||||
from the URL. These are only meant for development purposes and local environments.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
https://admin.etke.cc?username=admin&server=https://matrix.example.com
|
||||
```
|
||||
|
||||
This will open `Credentials` (username/password) login form with the username field prefilled with `admin` and the
|
||||
Homeserver URL field prefilled with `https://matrix.example.com`.
|
||||
|
||||
### Development and Local environments
|
||||
|
||||
**With Password**
|
||||
|
||||
```bash
|
||||
http://localhost:8080?username=admin&server=https://matrix.example.com&password=secret
|
||||
```
|
||||
|
||||
This will open `Credentials` (username/password) login form with the username field prefilled with `admin`, the
|
||||
Homeserver URL field prefilled with `https://matrix.example.com` and the password field prefilled with `secret`.
|
||||
|
||||
|
||||
**With Access Token**
|
||||
|
||||
```bash
|
||||
http://localhost:8080?server=https://matrix.example.com&accessToken=secret
|
||||
```
|
||||
|
||||
This will open `Access Token` login form with the Homeserver URL field prefilled with `https://matrix.example.com` and
|
||||
the access token field prefilled with `secret`.
|
44
docs/restrict-hs.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Restricting available homeserver
|
||||
|
||||
If you want to have your Synapse Admin instance work only with specific homeserver(-s),
|
||||
you can do that by setting `restrictBaseUrl` in the configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can do that for a single homeserver or multiple homeservers at once, as `restrictBaseUrl` accepts both a string and
|
||||
an array of strings.
|
||||
|
||||
The examples below contain the configuration settings to restrict the Synapse Admin instance to work only with
|
||||
`example.com` (with Synapse runing at `matrix.example.com`) and
|
||||
`example.net` (with Synapse running at `synapse.example.net`) homeservers.
|
||||
Note that the homeserver URL should be the _actual_ homeserver URL, and not the delegated one.
|
||||
|
||||
So, if you have a homeserver `example.com` where users have MXIDs like `@user:example.com`,
|
||||
but actual Synapse is installed on `matrix.example.com` subdomain, you should use `https://matrix.example.com` in the
|
||||
configuration.
|
||||
|
||||
[Configuration options](config.md)
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"restrictBaseUrl": [
|
||||
"https://matrix.example.com",
|
||||
"https://synapse.example.net"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `/.well-known/matrix/client`
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"restrictBaseUrl": [
|
||||
"https://matrix.example.com",
|
||||
"https://synapse.example.net"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
53
docs/reverse-proxy.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Serving Synapse Admin behind a reverse proxy
|
||||
|
||||
Your are supposed to do so for any service you want to expose to the internet,
|
||||
and here you can find specific instructions and example configurations for Synapse Admin.
|
||||
|
||||
## Nginx
|
||||
|
||||
Place the config below into `/etc/nginx/conf.d/synapse-admin.conf` (don't forget to replace `server_name` and `root`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name example.com; # REPLACE with your domain
|
||||
root /var/www/synapse-admin; # REPLACE with path where you extracted synapse admin
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$ {
|
||||
expires 30d; # Set caching for static assets
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain application/javascript application/json text/css text/xml application/xml+rss;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
```
|
||||
|
||||
After you've done that, ensure that the configuration is correct by running `nginx -t` and then reload Nginx
|
||||
(e.g. `systemctl reload nginx`).
|
||||
|
||||
> **Note:** This configuration doesn't cover HTTPS, which is highly recommended to use. You can find more information
|
||||
about setting up HTTPS in the [Nginx documentation](https://nginx.org/en/docs/http/configuring_https_servers.html).
|
||||
|
||||
## Traefik (docker labels)
|
||||
|
||||
If you are using Traefik as a reverse proxy, you can use the following labels, `docker-compose.yml` example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
synapse-admin:
|
||||
image: ghcr.io/etkecc/synapse-admin:latest
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)"
|
||||
```
|
||||
|
||||
## Other reverse proxies
|
||||
|
||||
There is no examples for other reverse proxies yet, and so PRs are greatly appreciated.
|
44
docs/system-users.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# System / Appservice-managed Users
|
||||
|
||||
Inadvertently altering system user accounts managed by appservices (such as bridges) / system (such as bots) is a common issue.
|
||||
Editing, deleting, locking, or changing the passwords of these appservice-managed accounts can cause serious problems.
|
||||
To prevent this, we've added a new feature that blocks these types of modifications to such accounts,
|
||||
while still allowing other risk-free changes (changing display names and avatars).
|
||||
By defining a list of MXID regex patterns in the new `asManagedUsers` configuration setting,
|
||||
you can protect these accounts from accidental changes.
|
||||
|
||||
## Configuration
|
||||
|
||||
The examples below contain the configuration settings to mark
|
||||
[Telegram bridge (mautrix-telegram)](https://github.com/mautrix/telegram),
|
||||
[Slack bridge (mautrix-slack)](https://github.com/mautrix/slack),
|
||||
and [Baibot](https://github.com/etkecc/baibot) users of `example.com` homeserver as appservice-managed users,
|
||||
just to illustrate the options to protect both specific MXIDs (as in the Baibot example) and all puppets of a bridge (as in the Telegram and Slack examples).
|
||||
|
||||
[Configuration options](config.md)
|
||||
|
||||
### config.json
|
||||
|
||||
```json
|
||||
"asManagedUsers": [
|
||||
"^@baibot:example\\.com$",
|
||||
"^@slackbot:example\\.com$",
|
||||
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||
"^@telegrambot:example\\.com$",
|
||||
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||
]
|
||||
```
|
||||
|
||||
### `/.well-known/matrix/client`
|
||||
|
||||
```json
|
||||
"cc.etke.synapse-admin": {
|
||||
"asManagedUsers": [
|
||||
"^@baibot:example\\.com$",
|
||||
"^@slackbot:example\\.com$",
|
||||
"^@slack_[a-zA-Z0-9\\-]+:example\\.com$",
|
||||
"^@telegrambot:example\\.com$",
|
||||
"^@telegram_[a-zA-Z0-9]+:example\\.com$"
|
||||
]
|
||||
}
|
||||
```
|
36
docs/user-badges.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# User Badges
|
||||
|
||||
To help with identifying users with certain roles or permissions, we have implemented a badge system.
|
||||
These badges are displayed on the user's avatar and have a handy tooltip that explains what the badge means.
|
||||
|
||||
## Available Badges
|
||||
|
||||
### 🧙 You
|
||||
|
||||
This badge is displayed on your user's avatar.
|
||||
Tooltip for this badge will contain additional information, e.g.: `You (Admin)`.
|
||||
|
||||
### 👑 Admin
|
||||
|
||||
This badge is displayed on homeserver admins' avatars.
|
||||
Tooltip for this badge is `Admin`.
|
||||
|
||||
### 🛡️ Appservice/System-managed
|
||||
|
||||
This badge is displayed on users that are managed by an appservices (or system), [more details](./system-users.md).
|
||||
Tooltip for this badge will contain additional information, e.g.: `System-managed (Bot)`.
|
||||
|
||||
### 🤖 Bot
|
||||
|
||||
This badge is displayed on bots' avatars (users with the `user_type` set to `bot`).
|
||||
Tooltip for this badge is `Bot`.
|
||||
|
||||
### 📞 Support
|
||||
|
||||
This badge is displayed on users that are part of the support team (users with the `user_type` set to `support`).
|
||||
Tooltip for this badge is `Support`.
|
||||
|
||||
### 👤 Regular User
|
||||
|
||||
This badge is displayed on regular users' avatars.
|
||||
Tooltip for this badge is `Regular User`.
|
22
index.html
@@ -4,17 +4,14 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Synapse-Admin"
|
||||
/>
|
||||
<meta name="description" content="Synapse Admin" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<title>Synapse-Admin</title>
|
||||
<title>Synapse Admin</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -22,6 +19,11 @@
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.layout {
|
||||
min-height: 90vh !important;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -120,13 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<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><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
|
||||
</a>
|
||||
</footer>
|
||||
<span id="js-version" style="display: none;"></span>
|
||||
</body>
|
||||
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||
<script>document.getElementById("js-version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||
</html>
|
||||
|
44
justfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Shows help
|
||||
default:
|
||||
@just --list --justfile {{ justfile() }}
|
||||
|
||||
# build the app
|
||||
build: __install
|
||||
@yarn run build --base=./
|
||||
|
||||
# run the app in a development mode
|
||||
run:
|
||||
@yarn start --host 0.0.0.0
|
||||
|
||||
# run dev stack and start the app in a development mode
|
||||
run-dev:
|
||||
@echo "Starting the database..."
|
||||
@docker-compose -f docker-compose-dev.yml up -d postgres
|
||||
@echo "Starting Synapse..."
|
||||
@docker-compose -f docker-compose-dev.yml up -d synapse
|
||||
@echo "Ensure admin user is registered..."
|
||||
@docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
|
||||
@echo "Starting the app..."
|
||||
@yarn start --host 0.0.0.0
|
||||
|
||||
# stop the dev stack
|
||||
stop-dev:
|
||||
@docker-compose -f docker-compose-dev.yml stop
|
||||
|
||||
# register a user in the dev stack
|
||||
register-user localpart password *admin:
|
||||
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
|
||||
|
||||
# run yarn {fix,lint,test} commands
|
||||
test:
|
||||
@-yarn run fix
|
||||
@-yarn run lint
|
||||
@-yarn run test
|
||||
|
||||
# run the app in a production mode
|
||||
run-prod: build
|
||||
@python -m http.server -d dist 1313
|
||||
|
||||
# install the project
|
||||
__install:
|
||||
@yarn install --immutable --network-timeout=300000
|
79
package.json
@@ -3,78 +3,77 @@
|
||||
"version": "0.10.3",
|
||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||
"type": "module",
|
||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
||||
"author": "etke.cc (originally by Awesome Technologies Innovationslabor GmbH)",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": ".",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Awesome-Technologies/synapse-admin"
|
||||
"url": "https://github.com/etkecc/synapse-admin"
|
||||
},
|
||||
"packageManager": "yarn@4.1.1",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.7.0",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "^18.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^18.3.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-yaml": "^1.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.4.2",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"ts-jest": "^29.2.3",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^7.16.1",
|
||||
"vite": "^5.3.4",
|
||||
"vite-plugin-version-mark": "^0.1.0"
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-version-mark": "^0.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@haleos/ra-language-german": "^1.0.0",
|
||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||
"@mui/icons-material": "^5.16.4",
|
||||
"@mui/material": "^5.16.4",
|
||||
"@mui/icons-material": "^6.2.0",
|
||||
"@mui/material": "^6.2.0",
|
||||
"@mui/utils": "^5.16.8",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"history": "^5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"papaparse": "^5.4.1",
|
||||
"query-string": "^7.1.3",
|
||||
"ra-core": "^4.16.20",
|
||||
"ra-i18n-polyglot": "^4.16.20",
|
||||
"ra-language-english": "^4.16.20",
|
||||
"ra-language-farsi": "^4.2.0",
|
||||
"ra-language-french": "^4.16.20",
|
||||
"ra-core": "^5.4.1",
|
||||
"ra-i18n-polyglot": "^5.3.4",
|
||||
"ra-language-english": "^5.3.4",
|
||||
"ra-language-farsi": "^5.1.0",
|
||||
"ra-language-french": "^5.4.2",
|
||||
"ra-language-italian": "^3.13.1",
|
||||
"ra-language-russian": "^4.14.2",
|
||||
"react": "^18.3.1",
|
||||
"react-admin": "^4.16.20",
|
||||
"react-admin": "^5.4.2",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-is": "^18.3.1",
|
||||
"react-query": "^3.39.3",
|
||||
"react-router": "^6.25.1",
|
||||
"react-router-dom": "^6.25.1"
|
||||
"react-router": "^6.26.2",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .",
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ignore-path .gitignore --ignore-pattern testdata/ --ext .ts,.tsx,.yml,.yaml .",
|
||||
"fix": "yarn lint --fix",
|
||||
"test": "yarn jest",
|
||||
"test:watch": "yarn jest --watch"
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/logo.webp
Normal file
After Width: | Height: | Size: 16 KiB |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"short_name": "Synapse-Admin",
|
||||
"name": "Synapse-Admin",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
BIN
screenshots/auth.webp
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/etke.cc/server-notifications/badge.webp
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
screenshots/etke.cc/server-notifications/page.webp
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
screenshots/etke.cc/server-status/indicator.webp
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
screenshots/etke.cc/server-status/page.webp
Normal file
After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
@@ -1,10 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
fetchMock.enableMocks();
|
||||
|
||||
import App from "./App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders", async () => {
|
||||
render(<App />);
|
||||
await screen.findAllByText("Welcome to Synapse-admin");
|
||||
await screen.findAllByText("Welcome to Synapse Admin");
|
||||
});
|
||||
});
|
||||
|
75
src/App.tsx
@@ -2,9 +2,11 @@ import { merge } from "lodash";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
|
||||
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
||||
|
||||
import { Route } from "react-router-dom";
|
||||
|
||||
import { ImportFeature } from "./components/ImportFeature";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import UserImport from "./components/UserImport";
|
||||
import germanMessages from "./i18n/de";
|
||||
import englishMessages from "./i18n/en";
|
||||
import frenchMessages from "./i18n/fr";
|
||||
@@ -21,6 +23,9 @@ import userMediaStats from "./resources/user_media_statistics";
|
||||
import users from "./resources/users";
|
||||
import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
||||
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
@@ -45,37 +50,43 @@ const i18nProvider = polyglotI18nProvider(
|
||||
]
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
disableTelemetry
|
||||
requireAuth
|
||||
loginPage={LoginPage}
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
darkTheme={{ palette: { mode: "dark" } }}
|
||||
>
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<ImportFeature />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
<Resource {...userMediaStats} />
|
||||
<Resource {...reports} />
|
||||
<Resource {...roomDirectory} />
|
||||
<Resource {...destinations} />
|
||||
<Resource {...registrationToken} />
|
||||
<Resource name="connections" />
|
||||
<Resource name="devices" />
|
||||
<Resource name="room_members" />
|
||||
<Resource name="users_media" />
|
||||
<Resource name="joined_rooms" />
|
||||
<Resource name="pushers" />
|
||||
<Resource name="servernotices" />
|
||||
<Resource name="forward_extremities" />
|
||||
<Resource name="room_state" />
|
||||
<Resource name="destination_rooms" />
|
||||
</Admin>
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Admin
|
||||
disableTelemetry
|
||||
requireAuth
|
||||
layout={AdminLayout}
|
||||
loginPage={LoginPage}
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
>
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<UserImport />} />
|
||||
<Route path="/server_status" element={<ServerStatusPage />} />
|
||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
<Resource {...rooms} />
|
||||
<Resource {...userMediaStats} />
|
||||
<Resource {...reports} />
|
||||
<Resource {...roomDirectory} />
|
||||
<Resource {...destinations} />
|
||||
<Resource {...registrationToken} />
|
||||
<Resource name="connections" />
|
||||
<Resource name="devices" />
|
||||
<Resource name="room_members" />
|
||||
<Resource name="users_media" />
|
||||
<Resource name="joined_rooms" />
|
||||
<Resource name="pushers" />
|
||||
<Resource name="servernotices" />
|
||||
<Resource name="forward_extremities" />
|
||||
<Resource name="room_state" />
|
||||
<Resource name="destination_rooms" />
|
||||
</Admin>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
@@ -1,9 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface AppContextType {
|
||||
restrictBaseUrl: string | string[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext({});
|
||||
|
||||
export const useAppContext = () => useContext(AppContext) as AppContextType;
|
7
src/Context.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { Config } from "./utils/config";
|
||||
|
||||
export const AppContext = createContext<Config>({} as Config);
|
||||
|
||||
export const useAppContext = () => useContext(AppContext) as Config;
|
104
src/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { CheckForApplicationUpdate, AppBar, TitlePortal, InspectorButton, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin";
|
||||
import { LoginMethod } from "../pages/LoginPage";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { Icons, DefaultIcon } from "../utils/icons";
|
||||
import { MenuItem, GetConfig, ClearConfig } from "../utils/config";
|
||||
import Footer from "./Footer";
|
||||
import ServerStatusBadge from "./etke.cc/ServerStatusBadge";
|
||||
import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge";
|
||||
|
||||
const AdminUserMenu = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const logout = useLogout();
|
||||
const checkLoginType = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod;
|
||||
if (loginType === "accessToken") {
|
||||
ev.stopPropagation();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setOpen(false);
|
||||
ClearConfig();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<UserMenu>
|
||||
<div onClickCapture={checkLoginType}>
|
||||
<Logout />
|
||||
</div>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="synapseadmin.auth.logout_acces_token_dialog.title"
|
||||
content="synapseadmin.auth.logout_acces_token_dialog.content"
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
confirm="synapseadmin.auth.logout_acces_token_dialog.confirm"
|
||||
cancel="synapseadmin.auth.logout_acces_token_dialog.cancel"
|
||||
/>
|
||||
</UserMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminAppBar = () => {
|
||||
return (<AppBar userMenu={<AdminUserMenu />}>
|
||||
<TitlePortal />
|
||||
<ServerStatusBadge />
|
||||
<ServerNotificationsBadge />
|
||||
<InspectorButton />
|
||||
</AppBar>);
|
||||
};
|
||||
|
||||
const AdminMenu = (props) => {
|
||||
const [menu, setMenu] = useState([] as MenuItem[]);
|
||||
useEffect(() => setMenu(GetConfig().menu), []);
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.ResourceItems />
|
||||
{menu && menu.map((item, index) => {
|
||||
const { url, icon, label } = item;
|
||||
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
|
||||
|
||||
return (
|
||||
<Suspense key={index}>
|
||||
<Menu.Item
|
||||
to={url}
|
||||
target="_blank"
|
||||
primaryText={label}
|
||||
leftIcon={IconComponent ? <IconComponent /> : <DefaultIcon />}
|
||||
onClick={props.onMenuClick}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminLayout = ({ children }) => {
|
||||
return <>
|
||||
<Layout appBar={AdminAppBar} menu={AdminMenu} sx={{
|
||||
['& .RaLayout-appFrame']: {
|
||||
minHeight: '90vh',
|
||||
height: '90vh',
|
||||
},
|
||||
['& .RaLayout-content']: {
|
||||
marginBottom: '3rem',
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
<CheckForApplicationUpdate />
|
||||
</Layout>
|
||||
<Footer />
|
||||
</>
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
@@ -1,18 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { RecordContextProvider } from "react-admin";
|
||||
|
||||
import { act } from "react";
|
||||
import AvatarField from "./AvatarField";
|
||||
|
||||
describe("AvatarField", () => {
|
||||
it("shows image", () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
|
||||
})
|
||||
) as jest.Mock;
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.only("shows image", async () => {
|
||||
const value = {
|
||||
avatar: "foo",
|
||||
avatar: "mxc://serverName/mediaId",
|
||||
};
|
||||
render(
|
||||
<RecordContextProvider value={value}>
|
||||
<AvatarField source="avatar" />
|
||||
</RecordContextProvider>
|
||||
);
|
||||
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<RecordContextProvider value={value}>
|
||||
<AvatarField source="avatar" />
|
||||
</RecordContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const img = screen.getByRole("img");
|
||||
expect(img.getAttribute("src")).toBe("mock-object-url");
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@@ -1,13 +1,107 @@
|
||||
import { get } from "lodash";
|
||||
import { Avatar, AvatarProps, Badge, Tooltip } from "@mui/material";
|
||||
import { FieldProps, useRecordContext, useTranslate } from "react-admin";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
import { isMXID, isASManaged } from "../utils/mxid";
|
||||
|
||||
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 AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
|
||||
const { alt, classes, sizes, sx, variant } = rest;
|
||||
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
|
||||
const record = useRecordContext(rest);
|
||||
const mxcURL = get(record, source)?.toString();
|
||||
|
||||
const [src, setSrc] = useState<string>("");
|
||||
|
||||
const fetchAvatar = useCallback(async (mxcURL: string) => {
|
||||
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
|
||||
const blob = await response.blob();
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
setSrc(blobURL);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mxcURL) {
|
||||
fetchAvatar(mxcURL);
|
||||
}
|
||||
|
||||
// Cleanup function to revoke the object URL
|
||||
return () => {
|
||||
if (src) {
|
||||
URL.revokeObjectURL(src);
|
||||
}
|
||||
};
|
||||
}, [mxcURL, fetchAvatar]);
|
||||
|
||||
// a hacky way to handle both users and rooms,
|
||||
// where users have an ID, may have a name, and may have a displayname
|
||||
// and rooms have an ID and may have a name
|
||||
let letter = "";
|
||||
if (record?.id) {
|
||||
letter = record.id[0].toUpperCase();
|
||||
}
|
||||
if (record?.name) {
|
||||
letter = record.name[0].toUpperCase();
|
||||
}
|
||||
if (record?.displayname) {
|
||||
letter = record.displayname[0].toUpperCase();
|
||||
}
|
||||
|
||||
// hacky way to determine the user type
|
||||
let badge = "";
|
||||
let tooltip = "";
|
||||
if (isMXID(record?.id)) {
|
||||
const translate = useTranslate();
|
||||
switch (record?.user_type) {
|
||||
case "bot":
|
||||
badge = "🤖";
|
||||
tooltip = translate("resources.users.badge.bot");
|
||||
break;
|
||||
case "support":
|
||||
badge = "📞";
|
||||
tooltip = translate("resources.users.badge.support");
|
||||
break;
|
||||
default:
|
||||
badge = "👤";
|
||||
tooltip = translate("resources.users.badge.regular");
|
||||
break;
|
||||
}
|
||||
if (record?.admin) {
|
||||
badge = "👑";
|
||||
tooltip = translate("resources.users.badge.admin");
|
||||
}
|
||||
if (isASManaged(record?.name)) {
|
||||
badge = "🛡️";
|
||||
tooltip = `${translate("resources.users.badge.system_managed")} (${tooltip})`;
|
||||
}
|
||||
if (localStorage.getItem("user_id") === record?.id) {
|
||||
badge = "🧙";
|
||||
tooltip = `${translate("resources.users.badge.you")} (${tooltip})`;
|
||||
}
|
||||
}
|
||||
|
||||
// if there is a badge, wrap the Avatar in a Badge and a Tooltip
|
||||
if (badge) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Badge
|
||||
badgeContent={badge}
|
||||
overlap="circular"
|
||||
sx={{ "& .MuiBadge-badge": { width: "10px" } }} // we deliberately set a very small width here, to make the badge actually circular
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||
{letter}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
</Tooltip>);
|
||||
}
|
||||
|
||||
return (<Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant}>
|
||||
{letter}
|
||||
</Avatar>);
|
||||
};
|
||||
|
||||
export default AvatarField;
|
||||
|
104
src/components/DeleteRoomButton.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||
import { Fragment, useState } from "react";
|
||||
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
|
||||
interface DeleteRoomButtonProps {
|
||||
selectedIds: Identifier[];
|
||||
confirmTitle: string;
|
||||
confirmContent: string;
|
||||
}
|
||||
|
||||
const resourceName = "rooms";
|
||||
|
||||
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||
const translate = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [block, setBlock] = useState(false);
|
||||
|
||||
const notify = useNotify();
|
||||
const redirect = useRedirect();
|
||||
|
||||
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||
const unselectAll = useUnselectAll(resourceName);
|
||||
const recordIds = props.selectedIds;
|
||||
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = (values: {block: boolean}) => {
|
||||
deleteMany(
|
||||
resourceName,
|
||||
{ ids: recordIds, meta: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.rooms.action.erase.success");
|
||||
handleDialogClose();
|
||||
unselectAll();
|
||||
redirect("/rooms");
|
||||
},
|
||||
onError: (error) =>
|
||||
notify("resources.rooms.action.erase.failure", { type: 'error' as NotificationType }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
handleDelete({ block: block });
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
onClick={handleDialogOpen}
|
||||
disabled={isLoading}
|
||||
className={"ra-delete-button"}
|
||||
key="button"
|
||||
size="small"
|
||||
sx={{
|
||||
"&.MuiButton-sizeSmall": {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
}}
|
||||
color={"error"}
|
||||
startIcon={<ActionDelete />}
|
||||
>
|
||||
{translate("ra.action.delete")}
|
||||
</Button>
|
||||
<Dialog open={open} onClose={handleDialogClose}>
|
||||
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||
<SimpleForm toolbar={false}>
|
||||
<BooleanInput
|
||||
source="block"
|
||||
value={block}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
|
||||
label="resources.rooms.action.erase.fields.block"
|
||||
defaultValue={false}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||
{translate("ra.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={false}
|
||||
onClick={handleConfirm}
|
||||
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||
autoFocus
|
||||
startIcon={<ActionCheck />}
|
||||
>
|
||||
{translate("ra.action.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteRoomButton;
|
117
src/components/DeleteUserButton.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||
import { Fragment, useState } from "react";
|
||||
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
|
||||
interface DeleteUserButtonProps {
|
||||
selectedIds: Identifier[];
|
||||
confirmTitle: string;
|
||||
confirmContent: string;
|
||||
}
|
||||
|
||||
const resourceName = "users";
|
||||
|
||||
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||
const translate = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteMedia, setDeleteMedia] = useState(false);
|
||||
const [redactEvents, setRedactEvents] = useState(false);
|
||||
|
||||
const notify = useNotify();
|
||||
const redirect = useRedirect();
|
||||
|
||||
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||
const unselectAll = useUnselectAll(resourceName);
|
||||
const recordIds = props.selectedIds;
|
||||
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
|
||||
deleteMany(
|
||||
resourceName,
|
||||
{ ids: recordIds, meta: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("ra.notification.deleted", {
|
||||
messageArgs: {
|
||||
smart_count: recordIds.length,
|
||||
},
|
||||
type: 'info' as NotificationType,
|
||||
});
|
||||
handleDialogClose();
|
||||
unselectAll();
|
||||
redirect("/users");
|
||||
},
|
||||
onError: (error) =>
|
||||
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
onClick={handleDialogOpen}
|
||||
disabled={isLoading}
|
||||
className={"ra-delete-button"}
|
||||
key="button"
|
||||
size="small"
|
||||
sx={{
|
||||
"&.MuiButton-sizeSmall": {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
}}
|
||||
color={"error"}
|
||||
startIcon={<ActionDelete />}
|
||||
>
|
||||
{translate("ra.action.delete")}
|
||||
</Button>
|
||||
<Dialog open={open} onClose={handleDialogClose}>
|
||||
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||
<SimpleForm toolbar={false}>
|
||||
<BooleanInput
|
||||
source="deleteMedia"
|
||||
value={deleteMedia}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
|
||||
label="resources.users.action.delete_media"
|
||||
defaultValue={false}
|
||||
/>
|
||||
<BooleanInput
|
||||
source="redactEvents"
|
||||
value={redactEvents}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
|
||||
label="resources.users.action.redact_events"
|
||||
defaultValue={false}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||
{translate("ra.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={false}
|
||||
onClick={handleConfirm}
|
||||
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||
autoFocus
|
||||
startIcon={<ActionCheck />}
|
||||
>
|
||||
{translate("ra.action.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteUserButton;
|
@@ -1,9 +1,15 @@
|
||||
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
||||
import { isASManaged } from "../utils/mxid";
|
||||
|
||||
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||
const record = useRecordContext();
|
||||
if (!record) return null;
|
||||
|
||||
let isASManagedUser = false;
|
||||
if (record.user_id) {
|
||||
isASManagedUser = isASManaged(record.user_id);
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteWithConfirmButton
|
||||
{...props}
|
||||
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||
confirmContent="resources.devices.action.erase.content"
|
||||
mutationMode="pessimistic"
|
||||
redirect={false}
|
||||
disabled={isASManagedUser}
|
||||
translateOptions={{
|
||||
id: record.id,
|
||||
name: record.display_name ? record.display_name : record.id,
|
||||
@@ -19,3 +26,5 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceRemoveButton;
|
97
src/components/ExperimentalFeatures.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useRecordContext } from "react-admin";
|
||||
import { useNotify } from "react-admin";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Switch, Typography } from "@mui/material";
|
||||
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
|
||||
|
||||
const experimentalFeaturesMap = {
|
||||
msc3881: "enable remotely toggling push notifications for another client",
|
||||
msc3575: "enable experimental sliding sync support",
|
||||
};
|
||||
const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => {
|
||||
const featureKey = props.featureKey;
|
||||
const featureValue = props.featureValue;
|
||||
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
|
||||
const [checked, setChecked] = useState(featureValue);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setChecked(event.target.checked);
|
||||
props.updateFeature(featureKey, event.target.checked);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Switch checked={checked} onChange={handleChange} />
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: "medium",
|
||||
color: "text.primary"
|
||||
}}
|
||||
>
|
||||
{featureKey}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{featureDescription}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
|
||||
export const ExperimentalFeaturesList = () => {
|
||||
const record = useRecordContext();
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||
const [features, setFeatures] = useState({});
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeatures = async () => {
|
||||
const features = await dataProvider.getFeatures(record.id);
|
||||
setFeatures(features);
|
||||
}
|
||||
|
||||
fetchFeatures();
|
||||
}, []);
|
||||
|
||||
const updateFeature = async (feature_name: string, feature_value: boolean) => {
|
||||
const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel;
|
||||
setFeatures(updatedFeatures);
|
||||
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
|
||||
notify("ra.notification.updated", {
|
||||
messageArgs: { smart_count: 1 },
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
return <>
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
>
|
||||
{Object.keys(features).map((featureKey: string) =>
|
||||
<ExperimentalFeatureRow
|
||||
key={featureKey}
|
||||
featureKey={featureKey}
|
||||
featureValue={features[featureKey]}
|
||||
updateFeature={updateFeature}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
}
|
||||
|
||||
export default ExperimentalFeaturesList;
|
49
src/components/Footer.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Avatar, Box, Link, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
const Footer = () => {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const version = document.getElementById("js-version")?.textContent;
|
||||
if (version) {
|
||||
setVersion(version);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
bgcolor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
borderTop: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
fontSize: '0.89rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
p: 1,
|
||||
gap: '10px'
|
||||
}}>
|
||||
<Avatar src="./images/logo.webp" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />
|
||||
<Link href="https://github.com/etkecc/synapse-admin" target="_blank">
|
||||
Synapse Admin {version}
|
||||
</Link>
|
||||
by
|
||||
<Link href="https://etke.cc/?utm_source=synapse-admin&utm_medium=footer&utm_campaign=synapse-admin" target="_blank">
|
||||
etke.cc
|
||||
</Link>
|
||||
(originally developed by Awesome Technologies Innovationslabor GmbH).
|
||||
<Link sx={{ fontWeight: 'bold' }} href="https://matrix.to/#/#synapse-admin:etke.cc" target="_blank">#synapse-admin:etke.cc</Link>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
59
src/components/LoginFormBox.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
const LoginFormBox = styled(Box)(({ theme }) => ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "calc(100vh - 1rem)",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
background: "url(./images/floating-cogs.svg)",
|
||||
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.background.default : theme.palette.background.paper,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
|
||||
[`& .card`]: {
|
||||
width: "30rem",
|
||||
marginTop: "6rem",
|
||||
marginBottom: "6rem",
|
||||
},
|
||||
[`& .avatar`]: {
|
||||
margin: "1rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
},
|
||||
[`& .icon`]: {
|
||||
backgroundColor: theme.palette.grey[500],
|
||||
},
|
||||
[`& .hint`]: {
|
||||
marginTop: "1em",
|
||||
marginBottom: "1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
[`& .form`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .select`]: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
[`& .actions`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .serverVersion`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
[`& .matrixVersions`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
marginBottom: "1rem",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
}
|
||||
));
|
||||
|
||||
export default LoginFormBox;
|
@@ -20,7 +20,7 @@ import {
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
@@ -43,7 +43,6 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||
<TextInput
|
||||
source="body"
|
||||
label="resources.servernotices.fields.body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows="4"
|
||||
resettable
|
||||
@@ -64,6 +63,10 @@ export const ServerNoticeButton = () => {
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSend = (values: Partial<RaRecord>) => {
|
||||
create(
|
||||
"servernotices",
|
||||
@@ -100,28 +103,26 @@ export const ServerNoticeBulkButton = () => {
|
||||
const unselectAllUsers = useUnselectAll("users");
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
const { mutate: sendNotices, isLoading } = useMutation(
|
||||
data =>
|
||||
const { mutate: sendNotices, isPending } = useMutation({
|
||||
mutationFn: (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",
|
||||
}),
|
||||
}
|
||||
);
|
||||
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}>
|
||||
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
|
||||
<MessageIcon />
|
||||
</Button>
|
||||
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
|
||||
|
@@ -15,7 +15,8 @@ import {
|
||||
import { DataProvider, useTranslate } from "ra-core";
|
||||
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
|
||||
|
||||
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
|
||||
import { generateRandomMXID, returnMXID } from "../utils/mxid";
|
||||
import { generateRandomPassword } from "../utils/password";
|
||||
|
||||
const LOGGING = true;
|
||||
|
||||
@@ -74,7 +75,7 @@ const FilePicker = () => {
|
||||
|
||||
const [conflictMode, setConflictMode] = useState("stop");
|
||||
const [passwordMode, setPasswordMode] = useState(true);
|
||||
const [useridMode, setUseridMode] = useState("ignore");
|
||||
const [useridMode, setUseridMode] = useState("update");
|
||||
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
@@ -121,7 +122,8 @@ const FilePicker = () => {
|
||||
|
||||
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
||||
/* First, verify the presence of required fields */
|
||||
const missingFields = expectedFields.filter(eF => meta.fields?.find(mF => eF === mF));
|
||||
meta.fields = meta.fields?.map(f => f.trim().toLowerCase());
|
||||
const missingFields = expectedFields.filter(eF => !meta.fields?.find(mF => eF === mF));
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
||||
@@ -147,6 +149,15 @@ const FilePicker = () => {
|
||||
};
|
||||
|
||||
const errorMessages = errors.map(e => e.message);
|
||||
// sanitize the data first
|
||||
data = data.map(line => {
|
||||
const newLine = {} as ImportLine;
|
||||
for (const [key, value] of Object.entries(line)) {
|
||||
newLine[key.trim().toLowerCase()] = value;
|
||||
}
|
||||
return newLine;
|
||||
});
|
||||
// process the data
|
||||
data.forEach((line, idx) => {
|
||||
if (line.user_type === undefined || line.user_type === "") {
|
||||
stats.user_types.default++;
|
||||
@@ -173,6 +184,7 @@ const FilePicker = () => {
|
||||
line[f] = true; // we need true booleans instead of strings
|
||||
} else {
|
||||
if (line[f] !== "false" && line[f] !== "") {
|
||||
console.log("invalid value", line[f], "for field " + f + " in row " + idx);
|
||||
errorMessages.push(
|
||||
translate("import_users.error.invalid_value", {
|
||||
field: f,
|
||||
@@ -262,12 +274,15 @@ const FilePicker = () => {
|
||||
const userRecord = { ...entry };
|
||||
// No need to do a bunch of cryptographic random number getting if
|
||||
// we are using neither a generated password nor a generated user id.
|
||||
if (useridMode === "ignore" || userRecord.id === undefined) {
|
||||
userRecord.id = generateRandomMxId();
|
||||
if (useridMode === "ignore" || userRecord.id === undefined || userRecord.id === "") {
|
||||
userRecord.id = generateRandomMXID();
|
||||
}
|
||||
if (passwordMode === false || entry.password === undefined) {
|
||||
if (passwordMode === false || entry.password === undefined || entry.password === "") {
|
||||
userRecord.password = generateRandomPassword();
|
||||
}
|
||||
// we want to ensure that the ID is always full MXID, otherwise randomly-generated MXIDs will be in the full
|
||||
// form, but the ones from the CSV will be localpart-only.
|
||||
userRecord.id = returnMXID(userRecord.id);
|
||||
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
||||
|
||||
/* For these modes we will consider the ID that's in the record.
|
||||
@@ -311,7 +326,7 @@ const FilePicker = () => {
|
||||
);
|
||||
} else {
|
||||
const newRecordData = Object.assign({}, recordData, {
|
||||
id: generateRandomMxId(),
|
||||
id: generateRandomMXID(),
|
||||
});
|
||||
retries++;
|
||||
if (retries > 512) {
|
||||
@@ -556,4 +571,5 @@ const FilePicker = () => {
|
||||
return [<Title defaultTitle={translate("import_users.title")} />, cardContainer];
|
||||
};
|
||||
|
||||
export const ImportFeature = FilePicker;
|
||||
export const UserImport = FilePicker;
|
||||
export default UserImport;
|
97
src/components/UserRateLimits.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin";
|
||||
import { TextField } from "@mui/material";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
const RateLimitRow = ({ limit, value, updateRateLimit }: { limit: string, value: any, updateRateLimit: (limit: string, value: any) => void }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (isNaN(value)) {
|
||||
updateRateLimit(limit, null);
|
||||
return;
|
||||
}
|
||||
updateRateLimit(limit, value);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
spacing={1}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
id="outlined-number"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
label={translate(`resources.users.limits.${limit}`)}
|
||||
/>
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{translate(`resources.users.limits.${limit}_text`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
|
||||
const UserRateLimits = () => {
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
const record = useRecordContext();
|
||||
const form = useFormContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const [rateLimits, setRateLimits] = useState({
|
||||
messages_per_second: "", // we are setting string here to make the number field empty by default, null is prohibited by the field validation
|
||||
burst_count: "",
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRateLimits = async () => {
|
||||
const rateLimits = await dataProvider.getRateLimits(record.id);
|
||||
if (Object.keys(rateLimits).length > 0) {
|
||||
setRateLimits(rateLimits);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRateLimits();
|
||||
}, []);
|
||||
|
||||
const updateRateLimit = async (limit: string, value: any) => {
|
||||
let updatedRateLimits = { ...rateLimits, [limit]: value };
|
||||
setRateLimits(updatedRateLimits);
|
||||
form.setValue(`rates.${limit}`, value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return <>
|
||||
<Stack
|
||||
direction="column"
|
||||
>
|
||||
{Object.keys(rateLimits).map((limit: string) =>
|
||||
<RateLimitRow
|
||||
key={limit}
|
||||
limit={limit}
|
||||
value={rateLimits[limit]}
|
||||
updateRateLimit={updateRateLimit}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
};
|
||||
|
||||
export default UserRateLimits;
|
42
src/components/etke.cc/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# etke.cc-specific components
|
||||
|
||||
This directory contains [etke.cc](https://etke.cc)-specific components, unusable for any other purposes and/or configuration.
|
||||
|
||||
We at [etke.cc](https://etke.cc) attempting to develop everything open-source, but some things are too specific to be used by anyone else. This directory contains such components - they are only available for [etke.cc](https://etke.cc) customers.
|
||||
|
||||
Due to the specifics mentioned above, these components are documented here rather than in the [docs](../../../docs/README.md), plus they are not supported as part of the Synapse Admin open-source project (i.e.: no issues, no PRs, no support, no requests, etc.).
|
||||
|
||||
## Components
|
||||
|
||||
### Server Status icon
|
||||
|
||||

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

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

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

|
||||
|
||||
When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server.
|
184
src/components/etke.cc/ServerNotificationsBadge.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Badge, useTheme, Button, Paper, Popper, ClickAwayListener, Box, List, ListItem, ListItemText, Typography, ListSubheader, IconButton, Divider, Tooltip } from "@mui/material";
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useDataProvider, useStore } from "react-admin";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useAppContext } from "../../Context";
|
||||
import { ServerNotificationsResponse } from "../../synapse/dataProvider";
|
||||
|
||||
const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000;
|
||||
|
||||
const useServerNotifications = () => {
|
||||
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", { notifications: [], success: false });
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const { notifications, success } = serverNotifications;
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin);
|
||||
setServerNotifications({
|
||||
...notificationsResponse,
|
||||
notifications: notificationsResponse.notifications,
|
||||
success: notificationsResponse.success
|
||||
});
|
||||
};
|
||||
|
||||
const deleteServerNotifications = async () => {
|
||||
const deleteResponse = await dataProvider.deleteServerNotifications(etkeccAdmin);
|
||||
if (deleteResponse.success) {
|
||||
await fetchNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let serverNotificationsInterval: NodeJS.Timeout;
|
||||
if (etkeccAdmin) {
|
||||
fetchNotifications();
|
||||
setTimeout(() => {
|
||||
// start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests
|
||||
serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME);
|
||||
}, SERVER_NOTIFICATIONS_INTERVAL_TIME);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (serverNotificationsInterval) {
|
||||
clearInterval(serverNotificationsInterval);
|
||||
}
|
||||
}
|
||||
}, [etkeccAdmin]);
|
||||
|
||||
return { success, notifications, deleteServerNotifications };
|
||||
};
|
||||
|
||||
export const ServerNotificationsBadge = () => {
|
||||
const navigate = useNavigate();
|
||||
const { success, notifications, deleteServerNotifications } = useServerNotifications();
|
||||
const theme = useTheme();
|
||||
|
||||
// Modify menu state to work with Popper
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(anchorEl ? null : event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSeeAllNotifications = () => {
|
||||
handleClose();
|
||||
navigate("/server_notifications");
|
||||
};
|
||||
|
||||
const handleClearAllNotifications = async () => {
|
||||
deleteServerNotifications()
|
||||
handleClose();
|
||||
};
|
||||
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<IconButton onClick={handleOpen} sx={{ color: theme.palette.common.white }}>
|
||||
<Tooltip title={notifications && notifications.length > 0 ? `${notifications.length} new notifications` : `No notifications yet`}>
|
||||
{notifications && notifications.length > 0 && (
|
||||
<Badge badgeContent={notifications.length} color="error">
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
) || <NotificationsIcon />}
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-end"
|
||||
style={{ zIndex: 1300 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 1,
|
||||
maxHeight: "350px",
|
||||
overflowY: "auto",
|
||||
minWidth: "300px",
|
||||
maxWidth: {
|
||||
xs: "100vw", // Full width on mobile
|
||||
sm: "400px" // Fixed width on desktop
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(!notifications || notifications.length === 0) ? (
|
||||
<Typography sx={{ p: 1 }} variant="body2">No new notifications</Typography>
|
||||
) : (
|
||||
<List sx={{ p: 0 }} dense={true}>
|
||||
<ListSubheader
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "inherit",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Notifications</Typography>
|
||||
<Box sx={{ cursor: "pointer", color: theme.palette.primary.main }} onClick={() => handleSeeAllNotifications()}>See all notifications</Box>
|
||||
</ListSubheader>
|
||||
<Divider />
|
||||
{notifications.map((notification, index) => {
|
||||
return (<Fragment key={notification.event_id ? notification.event_id : index }>
|
||||
<ListItem
|
||||
onClick={() => handleSeeAllNotifications()}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
cursor: "pointer"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: notification.output.split("\n")[0] }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</Fragment>
|
||||
)})}
|
||||
<ListItem>
|
||||
<Button
|
||||
key="clear-all-notifications"
|
||||
onClick={() => handleClearAllNotifications()}
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
pl: 0,
|
||||
pt: 1,
|
||||
verticalAlign: "middle"
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
Clear all
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
};
|
58
src/components/etke.cc/ServerNotificationsPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Box, Typography, Paper, Button } from "@mui/material"
|
||||
import { Stack } from "@mui/material"
|
||||
import { useStore } from "react-admin"
|
||||
import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider"
|
||||
import { useAppContext } from "../../Context";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
const DisplayTime = ({ date }: { date: string }) => {
|
||||
const dateFromDateString = new Date(date);
|
||||
return <>{dateFromDateString.toLocaleString()}</>;
|
||||
};
|
||||
|
||||
const ServerNotificationsPage = () => {
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const [serverNotifications, setServerNotifications] = useStore<ServerNotificationsResponse>("serverNotifications", {
|
||||
notifications: [],
|
||||
success: false,
|
||||
});
|
||||
|
||||
const notifications = serverNotifications.notifications;
|
||||
|
||||
return (
|
||||
<Stack spacing={3} mt={3}>
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 1 }}>
|
||||
<Typography variant="h4">Server Notifications</Typography>
|
||||
<Button variant="text" color="error" onClick={async () => {
|
||||
await dataProvider.deleteServerNotifications(etkeccAdmin);
|
||||
setServerNotifications({
|
||||
notifications: [],
|
||||
success: true,
|
||||
});
|
||||
}}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Clear
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography>No new notifications.</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
notifications.map((notification, index) => (
|
||||
<Paper key={notification.event_id ? notification.event_id : index} sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" color="text.secondary">
|
||||
<DisplayTime date={notification.sent_at} />
|
||||
</Typography>
|
||||
<Typography dangerouslySetInnerHTML={{ __html: notification.output }} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerNotificationsPage;
|
169
src/components/etke.cc/ServerStatusBadge.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Avatar, Badge, Theme, Tooltip } from "@mui/material";
|
||||
import { useEffect } from "react";
|
||||
import { useAppContext } from "../../Context";
|
||||
import { Button, useDataProvider, useStore } from "react-admin";
|
||||
import { styled } from '@mui/material/styles';
|
||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||
import { BadgeProps } from "@mui/material/Badge";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ServerProcessResponse, ServerStatusResponse } from "../../synapse/dataProvider";
|
||||
|
||||
interface StyledBadgeProps extends BadgeProps {
|
||||
backgroundColor: string;
|
||||
badgeColor: string
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
const StyledBadge = styled(Badge, { shouldForwardProp: (prop) => !['badgeColor', 'backgroundColor'].includes(prop as string) })<StyledBadgeProps>
|
||||
(({ theme, backgroundColor, badgeColor }) => ({
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: backgroundColor,
|
||||
color: badgeColor,
|
||||
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
|
||||
'&::after': {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
animation: 'ripple 2.5s infinite ease-in-out',
|
||||
border: '1px solid currentColor',
|
||||
content: '""',
|
||||
},
|
||||
},
|
||||
'@keyframes ripple': {
|
||||
'0%': {
|
||||
transform: 'scale(.8)',
|
||||
opacity: 1,
|
||||
},
|
||||
'100%': {
|
||||
transform: 'scale(2.4)',
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// every 5 minutes
|
||||
const SERVER_STATUS_INTERVAL_TIME = 5 * 60 * 1000;
|
||||
// every 5 seconds
|
||||
const SERVER_CURRENT_PROCCESS_INTERVAL_TIME = 5 * 1000;
|
||||
|
||||
const useServerStatus = () => {
|
||||
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", { ok: false, success: false, host: "", results: [] });
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const isOkay = serverStatus.ok;
|
||||
const successCheck = serverStatus.success;
|
||||
|
||||
const checkServerStatus = async () => {
|
||||
const serverStatus: ServerStatusResponse = await dataProvider.getServerStatus(etkeccAdmin);
|
||||
setServerStatus({
|
||||
ok: serverStatus.ok,
|
||||
success: serverStatus.success,
|
||||
host: serverStatus.host,
|
||||
results: serverStatus.results,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let serverStatusInterval: NodeJS.Timeout;
|
||||
if (etkeccAdmin) {
|
||||
checkServerStatus();
|
||||
setTimeout(() => {
|
||||
// start the interval after 10 seconds to avoid too many requests
|
||||
serverStatusInterval = setInterval(checkServerStatus, SERVER_STATUS_INTERVAL_TIME);
|
||||
}, 10000);
|
||||
} else {
|
||||
setServerStatus({ ok: false, success: false, host: "", results: [] });
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (serverStatusInterval) {
|
||||
clearInterval(serverStatusInterval);
|
||||
}
|
||||
}
|
||||
}, [etkeccAdmin]);
|
||||
|
||||
return { isOkay, successCheck };
|
||||
};
|
||||
|
||||
const useCurrentServerProcess = () => {
|
||||
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const { command, locked_at } = serverProcess;
|
||||
|
||||
const checkServerRunningProcess = async () => {
|
||||
const serverProcess: ServerProcessResponse = await dataProvider.getServerRunningProcess(etkeccAdmin);
|
||||
setServerProcess({
|
||||
...serverProcess,
|
||||
command: serverProcess.command,
|
||||
locked_at: serverProcess.locked_at
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let serverCheckInterval: NodeJS.Timeout;
|
||||
if (etkeccAdmin) {
|
||||
checkServerRunningProcess();
|
||||
setTimeout(() => {
|
||||
serverCheckInterval = setInterval(checkServerRunningProcess, SERVER_CURRENT_PROCCESS_INTERVAL_TIME);
|
||||
}, 5000);
|
||||
} else {
|
||||
setServerProcess({ command: "", locked_at: "" });
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (serverCheckInterval) {
|
||||
clearInterval(serverCheckInterval);
|
||||
}
|
||||
}
|
||||
}, [etkeccAdmin]);
|
||||
|
||||
return { command, locked_at };
|
||||
};
|
||||
|
||||
const ServerStatusBadge = () => {
|
||||
const { isOkay, successCheck } = useServerStatus();
|
||||
const { command, locked_at } = useCurrentServerProcess();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!successCheck) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleServerStatusClick = () => {
|
||||
navigate("/server_status");
|
||||
};
|
||||
|
||||
let tooltipText = "Click to view Server Status";
|
||||
let badgeBackgroundColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
|
||||
let badgeColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
|
||||
|
||||
if (command && locked_at) {
|
||||
badgeBackgroundColor = theme.palette.warning.main;
|
||||
badgeColor = theme.palette.warning.main;
|
||||
tooltipText = `Running: ${command}; ${tooltipText}`;
|
||||
}
|
||||
|
||||
return <Button onClick={handleServerStatusClick} size="medium" sx={{ minWidth: "auto", ".MuiButton-startIcon": { m: 0 }}}>
|
||||
<Tooltip title={tooltipText} sx={{ cursor: "pointer" }}>
|
||||
<StyledBadge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
variant="dot"
|
||||
backgroundColor={badgeBackgroundColor}
|
||||
badgeColor={badgeColor}
|
||||
>
|
||||
<Avatar sx={{ height: 24, width: 24, background: theme.palette.mode === "dark" ? theme.palette.background.default : "#2196f3" }}>
|
||||
<MonitorHeartIcon sx={{ height: 22, width: 22, color: theme.palette.common.white }} />
|
||||
</Avatar>
|
||||
</StyledBadge>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
};
|
||||
|
||||
export default ServerStatusBadge;
|
144
src/components/etke.cc/ServerStatusPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useStore } from "ra-core";
|
||||
import { Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
|
||||
|
||||
const getTimeSince = (date: string) => {
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInMinutes = Math.floor((now.getTime() - past.getTime()) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return "a couple of seconds";
|
||||
if (diffInMinutes === 1) return "1 minute";
|
||||
return `${diffInMinutes} minutes`;
|
||||
};
|
||||
|
||||
const StatusChip = ({ isOkay, size = "medium", command }: { isOkay: boolean, size?: "small" | "medium", command?: string }) => {
|
||||
let label = "OK";
|
||||
let icon = <CheckIcon />;
|
||||
let color: ChipProps["color"] = "success";
|
||||
if (!isOkay) {
|
||||
label = "Error";
|
||||
icon = <CloseIcon />;
|
||||
color = "error";
|
||||
}
|
||||
|
||||
if (command) {
|
||||
label = command;
|
||||
color = "warning";
|
||||
icon = <EngineeringIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip icon={icon} label={label} color={color} variant="outlined" size={size} />
|
||||
);
|
||||
};
|
||||
|
||||
const ServerComponentText = ({ text }: { text: string }) => {
|
||||
return <Typography variant="body1" dangerouslySetInnerHTML={{ __html: text }} />;
|
||||
};
|
||||
|
||||
const ServerStatusPage = () => {
|
||||
const [serverStatus, setServerStatus] = useStore<ServerStatusResponse>("serverStatus", {
|
||||
ok: false,
|
||||
success: false,
|
||||
host: "",
|
||||
results: [],
|
||||
});
|
||||
const [ serverProcess, setServerProcess ] = useStore<ServerProcessResponse>("serverProcess", { command: "", locked_at: "" });
|
||||
const { command, locked_at } = serverProcess;
|
||||
const successCheck = serverStatus.success;
|
||||
const isOkay = serverStatus.ok;
|
||||
const host = serverStatus.host;
|
||||
const results = serverStatus.results;
|
||||
|
||||
let groupedResults: Record<string, ServerStatusComponent[]> = {};
|
||||
for (const result of results) {
|
||||
if (!groupedResults[result.category]) {
|
||||
groupedResults[result.category] = [];
|
||||
}
|
||||
groupedResults[result.category].push(result);
|
||||
}
|
||||
|
||||
if (!successCheck) {
|
||||
return (
|
||||
<Paper elevation={3} sx={{ p: 3, mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CloseIcon color="error" />
|
||||
<Typography color="error">
|
||||
Unable to fetch server status. Please try again later.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} mt={3}>
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="h4">Status:</Typography>
|
||||
<StatusChip isOkay={isOkay} command={command} />
|
||||
</Box>
|
||||
<Typography variant="h5" color="primary" fontWeight="medium">
|
||||
{host}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{command && locked_at && (
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="h5">Currently running:</Typography>
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
<Link href={"https://etke.cc/help/extras/scheduler/#"+command} target="_blank">
|
||||
{command}
|
||||
</Link>
|
||||
<Tooltip title={locked_at.toString()}>
|
||||
<Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}>(started {getTimeSince(locked_at)} ago)</Typography>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack spacing={2} direction="row">
|
||||
{Object.keys(groupedResults).map((category, idx) => (
|
||||
<Box key={`category_${category}`} sx={{ flex: 1 }}>
|
||||
<Typography variant="h5" mb={1}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Paper elevation={2} sx={{ p: 3 }}>
|
||||
<Stack spacing={1} divider={<Divider />}>
|
||||
{groupedResults[category].map((result, idx) => (
|
||||
<Box key={`${category}_${idx}`}>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<StatusChip isOkay={result.ok} size="small" />
|
||||
{result.label.url ? (
|
||||
<Link href={result.label.url} target="_blank" rel="noopener noreferrer">
|
||||
<ServerComponentText text={result.label.text} />
|
||||
</Link>
|
||||
) : (
|
||||
<ServerComponentText text={result.label.text} />
|
||||
)}
|
||||
</Box>
|
||||
{result.reason && <Typography color="text.secondary" dangerouslySetInnerHTML={{ __html: result.reason }}/>}
|
||||
{(!result.ok && result.help) && (
|
||||
<Link href={result.help} target="_blank" rel="noopener noreferrer" sx={{ mt: 1 }}>
|
||||
Learn more
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerStatusPage;
|
@@ -8,7 +8,9 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import DownloadingIcon from '@mui/icons-material/Downloading';
|
||||
import { Grid2 as Grid, Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
BooleanInput,
|
||||
@@ -28,13 +30,11 @@ import {
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { dateParser } from "./date";
|
||||
import { dateParser } from "../utils/date";
|
||||
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
||||
import { getMediaUrl } from "../synapse/synapse";
|
||||
import storage from "../storage";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
|
||||
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
@@ -55,14 +55,12 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||
<DateTimeInput
|
||||
fullWidth
|
||||
source="before_ts"
|
||||
label="delete_media.fields.before_ts"
|
||||
defaultValue={0}
|
||||
parse={dateParser}
|
||||
/>
|
||||
<NumberInput
|
||||
fullWidth
|
||||
source="size_gt"
|
||||
label="delete_media.fields.size_gt"
|
||||
defaultValue={0}
|
||||
@@ -70,7 +68,6 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
step={1024}
|
||||
/>
|
||||
<BooleanInput
|
||||
fullWidth
|
||||
source="keep_profiles"
|
||||
label="delete_media.fields.keep_profiles"
|
||||
defaultValue={true}
|
||||
@@ -86,20 +83,18 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||
const { mutate: deleteMedia, isLoading } = useMutation(
|
||||
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("delete_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => {
|
||||
notify("delete_media.action.send_failure", {
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
const { mutate: deleteMedia, isPending } = useMutation({
|
||||
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||
onSuccess: () => {
|
||||
notify("delete_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => {
|
||||
notify("delete_media.action.send_failure", {
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const openDialog = () => setOpen(true);
|
||||
const closeDialog = () => setOpen(false);
|
||||
@@ -110,7 +105,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
{...props}
|
||||
label="delete_media.action.send"
|
||||
onClick={openDialog}
|
||||
disabled={isLoading}
|
||||
disabled={isPending}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
@@ -129,6 +124,80 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PurgeRemoteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const PurgeRemoteMediaToolbar = (props: ToolbarProps) => (
|
||||
<Toolbar {...props}>
|
||||
<SaveButton label="purge_remote_media.action.send" icon={<DeleteSweepIcon />} />
|
||||
<Button label="ra.action.cancel" onClick={onClose}>
|
||||
<IconCancel />
|
||||
</Button>
|
||||
</Toolbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>{translate("purge_remote_media.action.send")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{translate("purge_remote_media.helper.send")}</DialogContentText>
|
||||
<SimpleForm toolbar={<PurgeRemoteMediaToolbar />} onSubmit={onSubmit}>
|
||||
<DateTimeInput
|
||||
source="before_ts"
|
||||
label="purge_remote_media.fields.before_ts"
|
||||
defaultValue={0}
|
||||
parse={dateParser}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const PurgeRemoteMediaButton = (props: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||
const { mutate: purgeRemoteMedia, isPending } = useMutation({
|
||||
mutationFn: (values: DeleteMediaParams) => dataProvider.purgeRemoteMedia(values),
|
||||
onSuccess: () => {
|
||||
notify("purge_remote_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => {
|
||||
notify("purge_remote_media.action.send_failure", {
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const openDialog = () => setOpen(true);
|
||||
const closeDialog = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
{...props}
|
||||
label="purge_remote_media.action.send"
|
||||
onClick={openDialog}
|
||||
disabled={isPending}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
// Reset on mouse devices
|
||||
"@media (hover: none)": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteSweepIcon />
|
||||
</Button>
|
||||
<PurgeRemoteMediaDialog open={open} onClose={closeDialog} onSubmit={purgeRemoteMedia} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProtectMediaButton = (props: ButtonProps) => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
@@ -316,48 +385,126 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ViewMediaButton = ({ media_id, label }) => {
|
||||
export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||
const translate = useTranslate();
|
||||
const url = getMediaUrl(media_id);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const notify = useNotify();
|
||||
const isImage = mimetype && mimetype.startsWith("image/");
|
||||
|
||||
const openFileInNewTab = (blobURL: string) => {
|
||||
const anchorElement = document.createElement("a");
|
||||
anchorElement.href = blobURL;
|
||||
anchorElement.target = "_blank";
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
document.body.removeChild(anchorElement);
|
||||
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
|
||||
};
|
||||
|
||||
const downloadFile = async (blobURL: string) => {
|
||||
console.log("downloadFile", blobURL, uploadName);
|
||||
const anchorElement = document.createElement("a");
|
||||
anchorElement.href = blobURL;
|
||||
anchorElement.download = uploadName;
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
document.body.removeChild(anchorElement);
|
||||
setTimeout(() => URL.revokeObjectURL(blobURL), 10);;
|
||||
};
|
||||
|
||||
const handleFile = async (preview: boolean) => {
|
||||
setLoading(true);
|
||||
const response = await fetchAuthenticatedMedia(mxcURL, "original");
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
if (preview) {
|
||||
openFileInNewTab(blobURL);
|
||||
} else {
|
||||
downloadFile(blobURL);
|
||||
}
|
||||
} else {
|
||||
const body = await response.json();
|
||||
notify("resources.room_media.action.error", {
|
||||
messageArgs: {
|
||||
errcode: body.errcode,
|
||||
errstatus: response.status,
|
||||
error: body.error,
|
||||
},
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ whiteSpace: "pre" }}>
|
||||
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||
<span>
|
||||
<Button
|
||||
component={Link}
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
|
||||
>
|
||||
<FileOpenIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{label}
|
||||
</Box>
|
||||
<>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||
<span>
|
||||
{isImage && (
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => handleFile(true)}
|
||||
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||
>
|
||||
{loading ? <DownloadingIcon /> : <FileOpenIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => handleFile(false)}
|
||||
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||
>
|
||||
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||
</Button>
|
||||
<span>{label}</span>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MediaIDField = ({ source }) => {
|
||||
const homeserver = storage.getItem("home_server");
|
||||
const record = useRecordContext();
|
||||
if (!record) return null;
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
|
||||
const src = get(record, source)?.toString();
|
||||
if (!src) return null;
|
||||
const mediaID = get(record, source)?.toString();
|
||||
if (!mediaID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
|
||||
let uploadName = mediaID;
|
||||
if (get(record, "upload_name")) {
|
||||
uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
|
||||
}
|
||||
|
||||
let mxcURL = mediaID;
|
||||
if (!mediaID.startsWith(`mxc://${homeserver}`)) {
|
||||
// this is user's media, where mediaID doesn't have the mxc://home_server/ prefix as it has in the rooms
|
||||
mxcURL = `mxc://${homeserver}/${mediaID}`;
|
||||
}
|
||||
|
||||
return <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||
};
|
||||
|
||||
export const MXCField = ({ source }) => {
|
||||
export const ReportMediaContent = ({ source }) => {
|
||||
const record = useRecordContext();
|
||||
if (!record) return null;
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = get(record, source)?.toString();
|
||||
if (!src) return null;
|
||||
const mxcURL = get(record, source)?.toString();
|
||||
if (!mxcURL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const media_id = src.replace("mxc://", "");
|
||||
const uploadName = decodeURIComponent(get(record, "event_json.content.body")?.toString());
|
||||
|
||||
return <ViewMediaButton media_id={media_id} label={src} />;
|
||||
return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||
};
|
||||
|
134
src/i18n/de.ts
@@ -2,22 +2,60 @@ import { formalGermanMessages } from "@haleos/ra-language-german";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const de: SynapseTranslationMessages = {
|
||||
const fixedGermanMessages = {
|
||||
...formalGermanMessages,
|
||||
ra: {
|
||||
...formalGermanMessages.ra,
|
||||
navigation: {
|
||||
...formalGermanMessages.ra.navigation,
|
||||
no_filtered_results: "Keine Ergebnisse",
|
||||
clear_filters: "Alle Filter entfernen",
|
||||
add_filter: "Filter hinzufügen",
|
||||
},
|
||||
action: {
|
||||
...formalGermanMessages.ra.action,
|
||||
update_application: "Anwendung aktualisieren",
|
||||
},
|
||||
page: {
|
||||
...formalGermanMessages.ra.page,
|
||||
empty: "Leer",
|
||||
access_denied: "Zugriff verweigert",
|
||||
authentication_error: "Authentifizierungsfehler",
|
||||
},
|
||||
message: {
|
||||
...formalGermanMessages.ra.message,
|
||||
access_denied:
|
||||
"Sie haben nicht die erforderlichen Berechtigungen um auf diese Seite zuzugreifen.",
|
||||
authentication_error:
|
||||
"Der Authentifizierungsserver hat einen Fehler zurückgegeben und Ihre Anmeldedaten konnten nicht überprüft werden.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const de: SynapseTranslationMessages = {
|
||||
...fixedGermanMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Heimserver URL",
|
||||
welcome: "Willkommen bei Synapse-admin",
|
||||
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",
|
||||
credentials: "Anmeldedaten",
|
||||
access_token: "Zugriffstoken",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.",
|
||||
content: "Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?",
|
||||
confirm: "Sitzung beenden",
|
||||
cancel: "Nur vom Admin-Panel abmelden",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Raumdetails",
|
||||
@@ -26,6 +64,7 @@ const de: SynapseTranslationMessages = {
|
||||
members: "Mitglieder",
|
||||
detail: "Details",
|
||||
permission: "Berechtigungen",
|
||||
media: "Medien",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
|
||||
@@ -108,6 +147,20 @@ const de: SynapseTranslationMessages = {
|
||||
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Externe Medien",
|
||||
fields: {
|
||||
before_ts: "letzter Zugriff vor",
|
||||
},
|
||||
action: {
|
||||
send: "Externe Medien löschen",
|
||||
send_success: "Die Anfrage zum Löschen externer Medien wurde gesendet.",
|
||||
send_failure: "Bei der Anfrage zum Löschen externer Medien ist ein Fehler aufgetreten.",
|
||||
},
|
||||
helper: {
|
||||
send: "Diese API löscht den externen Medien-Cache von der Festplatte Ihres eigenen Servers. Dazu gehören alle lokalen Thumbnails und Kopien heruntergeladener Medien. Diese API beeinflusst nicht die Medien, die in das eigene Medienarchiv des Servers hochgeladen wurden.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Benutzer",
|
||||
@@ -121,10 +174,13 @@ const de: SynapseTranslationMessages = {
|
||||
is_guest: "Gast",
|
||||
admin: "Server Administrator",
|
||||
locked: "Gesperrt",
|
||||
suspended: "Suspendiert",
|
||||
deactivated: "Deaktiviert",
|
||||
erased: "Gelöscht",
|
||||
guests: "Zeige Gäste",
|
||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||
show_locked: "Zeige gesperrte Benutzer",
|
||||
show_suspended: "Zeige suspendierte Benutzer",
|
||||
user_id: "Suche Benutzer",
|
||||
displayname: "Anzeigename",
|
||||
password: "Passwort",
|
||||
@@ -140,12 +196,40 @@ const de: SynapseTranslationMessages = {
|
||||
},
|
||||
helper: {
|
||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||
create_password: "Generiere ein starkes und sicheres Passwort mit dem Button unten.",
|
||||
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
||||
suspend: "Ein gesperrter Benutzer kann sich nicht mehr anmelden und wird in den schreibgeschützten Modus versetzt.",
|
||||
erase: "DSGVO konformes Löschen der Benutzerdaten.",
|
||||
erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.",
|
||||
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
|
||||
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
|
||||
username_available: "Benutzername verfügbar",
|
||||
},
|
||||
badge: {
|
||||
you: "Sie",
|
||||
bot: "Bot",
|
||||
admin: "Administrator",
|
||||
support: "Unterstützung",
|
||||
regular: "Normaler Benutzer",
|
||||
system_managed: "Systemverwalteter Benutzer",
|
||||
},
|
||||
action: {
|
||||
erase: "Lösche Benutzerdaten",
|
||||
erase_avatar: "Avatar löschen",
|
||||
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
|
||||
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
|
||||
generate_password: "Passwort generieren",
|
||||
overwrite_title: "Warnung!",
|
||||
overwrite_content: "Dieser Benutzername ist bereits vergeben. Sind Sie sicher, dass Sie den vorhandenen Benutzer überschreiben möchten?",
|
||||
overwrite_cancel: "Abbrechen",
|
||||
overwrite_confirm: "Überschreiben",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Nachrichten pro Sekunde",
|
||||
messages_per_second_text: "Die Anzahl der Aktionen, die in einer Sekunde durchgeführt werden können.",
|
||||
burst_count: "Burst-Anzahl",
|
||||
burst_count_text: "Die Anzahl der Aktionen, die vor der Begrenzung durchgeführt werden können.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Raum |||| Räume",
|
||||
@@ -168,10 +252,11 @@ const de: SynapseTranslationMessages = {
|
||||
history_visibility: "Historie-Sichtbarkeit",
|
||||
topic: "Thema",
|
||||
avatar: "Avatar",
|
||||
actions: "Aktionen",
|
||||
},
|
||||
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.",
|
||||
"Vorderextremitäten sind Blatt-Ereignisse am Ende eines gerichteten azyklischen Graphens (DAG) in einem Raum, auch bekannt als Ereignisse ohne Nachkommen. Je mehr in einem Raum existieren, umso mehr Zustandsauflösungen muss Synapse absolvieren (Hinweis: dies ist eine sehr aufwendige Operation). Obwohl Synapse Code hat um zu verhindern, dass zuviele davon gleichzeitig in einem Raum existieren, können Bugs manchmal dafür sorgen, dass sie sich ansammeln. Wenn ein Raum >10 Vorderextremitäten hat ist es sinnvoll zu überprüfen um welchen Raum es sich handelt und sie gegebenenfalls, wie in #1769 beschrieben, mittels SQL-Queries zu entfernen.",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
@@ -196,8 +281,21 @@ const de: SynapseTranslationMessages = {
|
||||
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!",
|
||||
"Sind Sie sicher, dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
|
||||
fields: {
|
||||
block: "Blockieren und Benutzer daran hindern, dem Raum beizutreten",
|
||||
},
|
||||
success: "Raum/Räume erfolgreich gelöscht.",
|
||||
failure: "Der/die Raum/Räume konnten nicht gelöscht werden.",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Raumadministrator zuweisen",
|
||||
title: "Raumadministrator zu %{roomName} zuweisen",
|
||||
confirm: "Raumadministrator zuweisen",
|
||||
content: "Geben Sie die vollständige MXID des Benutzers an, der als Administrator gesetzt werden soll.\nWarnung: Damit dies funktioniert, muss der Raum mindestens ein lokales Mitglied als Administrator haben.",
|
||||
success: "Der/die Benutzer wurde/n als Raumadministrator gesetzt.",
|
||||
failure: "Der/die Benutzer konnte/n nicht als Raumadministrator gesetzt werden. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -231,7 +329,7 @@ const de: SynapseTranslationMessages = {
|
||||
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.",
|
||||
"Sind Sie sicher, dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -278,8 +376,8 @@ const de: SynapseTranslationMessages = {
|
||||
},
|
||||
protect_media: {
|
||||
action: {
|
||||
create: "Ungeschützt, Schutz erstellen",
|
||||
delete: "Geschützt, Schutz aufheben",
|
||||
create: "Ungeschützt, Schutz aktivieren",
|
||||
delete: "Geschützt, Schutz deaktivieren",
|
||||
none: "In Quarantäne",
|
||||
send_success: "Erfolgreich den Schutz-Status geändert.",
|
||||
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
|
||||
@@ -349,6 +447,18 @@ const de: SynapseTranslationMessages = {
|
||||
sender: "Absender",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "Medien",
|
||||
fields: {
|
||||
media_id: "Medien ID",
|
||||
},
|
||||
helper: {
|
||||
info: "Dies ist eine Liste der Medien, die in den Raum hochgeladen wurden. Es ist nicht möglich, Medien zu löschen, die in externen Medien-Repositories hochgeladen wurden.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
room_directory: {
|
||||
name: "Raumverzeichnis",
|
||||
fields: {
|
||||
@@ -359,8 +469,8 @@ const de: SynapseTranslationMessages = {
|
||||
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",
|
||||
erase: "Aus Verzeichnis löschen",
|
||||
create: "Ins Verzeichnis eintragen",
|
||||
send_success: "Raum erfolgreich eingetragen.",
|
||||
send_failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
||||
},
|
||||
@@ -382,7 +492,7 @@ const de: SynapseTranslationMessages = {
|
||||
fields: {
|
||||
token: "Token",
|
||||
valid: "Gültige Token",
|
||||
uses_allowed: "Anzahl",
|
||||
uses_allowed: "Verwendungen erlaubt",
|
||||
pending: "Ausstehend",
|
||||
completed: "Abgeschlossen",
|
||||
expiry_time: "Ablaufzeit",
|
||||
|
@@ -7,17 +7,29 @@ const en: SynapseTranslationMessages = {
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Homeserver URL",
|
||||
welcome: "Welcome to Synapse-admin",
|
||||
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",
|
||||
credentials: "Credentials",
|
||||
access_token: "Access token",
|
||||
logout_acces_token_dialog: {
|
||||
title: "You are using an existing Matrix access token.",
|
||||
content: "Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just logout from the admin panel?",
|
||||
confirm: "Destroy session",
|
||||
cancel: "Just logout from admin panel",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: {
|
||||
sso: "SSO",
|
||||
experimental: "Experimental",
|
||||
limits: "Rate Limits",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
details: "Room details",
|
||||
@@ -26,7 +38,8 @@ const en: SynapseTranslationMessages = {
|
||||
members: "Members",
|
||||
detail: "Details",
|
||||
permission: "Permissions",
|
||||
},
|
||||
media: "Media",
|
||||
}
|
||||
},
|
||||
reports: { tabs: { basic: "Basic", detail: "Details" } },
|
||||
},
|
||||
@@ -107,6 +120,20 @@ const en: SynapseTranslationMessages = {
|
||||
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Remote Media",
|
||||
fields: {
|
||||
before_ts: "last access before",
|
||||
},
|
||||
action: {
|
||||
send: "Purge remote media",
|
||||
send_success: "Purge remote media request has been sent.",
|
||||
send_failure: "An error has occurred with the purge remote media request.",
|
||||
},
|
||||
helper: {
|
||||
send: "This API purges the remote media cache from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to the server's own media repository.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "User |||| Users",
|
||||
@@ -120,10 +147,13 @@ const en: SynapseTranslationMessages = {
|
||||
is_guest: "Guest",
|
||||
admin: "Server Administrator",
|
||||
locked: "Locked",
|
||||
suspended: "Suspended",
|
||||
deactivated: "Deactivated",
|
||||
erased: "Erased",
|
||||
guests: "Show guests",
|
||||
show_deactivated: "Show deactivated users",
|
||||
show_locked: "Show locked users",
|
||||
show_suspended: "Show suspended users",
|
||||
user_id: "Search user",
|
||||
displayname: "Displayname",
|
||||
password: "Password",
|
||||
@@ -139,12 +169,40 @@ const en: SynapseTranslationMessages = {
|
||||
},
|
||||
helper: {
|
||||
password: "Changing password will log user out of all sessions.",
|
||||
create_password: "Generate a strong and secure password using the button below.",
|
||||
deactivate: "You must provide a password to re-activate an account.",
|
||||
suspend: "Suspending user means they are put into a read-only mode.",
|
||||
erase: "Mark the user as GDPR-erased",
|
||||
erase_text: "This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.",
|
||||
erase_admin_error: "Deleting own user is not allowed.",
|
||||
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
|
||||
username_available: "Username is available",
|
||||
},
|
||||
action: {
|
||||
erase: "Erase user data",
|
||||
erase_avatar: "Erase avatar",
|
||||
delete_media: "Delete all media uploaded by the user(-s)",
|
||||
redact_events: "Redact all events sent by the user(-s)",
|
||||
generate_password: "Generate password",
|
||||
overwrite_title: "Warning!",
|
||||
overwrite_content: "This username is already taken. Are you sure that you want to overwrite the existing user?",
|
||||
overwrite_cancel: "Cancel",
|
||||
overwrite_confirm: "Overwrite",
|
||||
},
|
||||
badge: {
|
||||
you: "You",
|
||||
bot: "Bot",
|
||||
admin: "Admin",
|
||||
support: "Support",
|
||||
regular: "Regular User",
|
||||
system_managed: "System-managed",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messages per second",
|
||||
messages_per_second_text: "The number of actions that can be performed in a second.",
|
||||
burst_count: "Burst count",
|
||||
burst_count_text: "How many actions that can be performed before being limited.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Room |||| Rooms",
|
||||
@@ -167,6 +225,7 @@ const en: SynapseTranslationMessages = {
|
||||
history_visibility: "History visibility",
|
||||
topic: "Topic",
|
||||
avatar: "Avatar",
|
||||
actions: "Actions",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
@@ -196,7 +255,20 @@ const en: SynapseTranslationMessages = {
|
||||
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!",
|
||||
fields: {
|
||||
block: "Block and prevent users from joining the room",
|
||||
},
|
||||
success: "Room/s successfully deleted.",
|
||||
failure: "The room/s could not be deleted.",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Assign admin",
|
||||
title: "Assign a room admin to %{roomName}",
|
||||
confirm: "Make admin",
|
||||
content: "Put the full MXID of the user which will be set as admin.\nWarning: for this to work, the room needs to have at least one local member as admin.",
|
||||
success: "The user has been set as room admin.",
|
||||
failure: "The user could not be set as room admin. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -347,6 +419,18 @@ const en: SynapseTranslationMessages = {
|
||||
sender: "Sender",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "Media ID",
|
||||
},
|
||||
helper: {
|
||||
info: "This is a list of media that has been uploaded to the room. It is not possible to delete media that has been uploaded to external media repositories.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}",
|
||||
},
|
||||
},
|
||||
room_directory: {
|
||||
name: "Room directory",
|
||||
fields: {
|
||||
|
@@ -13,10 +13,18 @@ const fa: SynapseTranslationMessages = {
|
||||
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
||||
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
||||
sso_sign_in: "با SSO وارد شوید",
|
||||
credentials: "اعتبارنامه",
|
||||
access_token: "توکن دسترسی",
|
||||
logout_acces_token_dialog: {
|
||||
title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.",
|
||||
content: "آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||
confirm: "نابودی جلسه",
|
||||
cancel: "فقط خروج از پنل مدیریت",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -24,6 +32,7 @@ const fa: SynapseTranslationMessages = {
|
||||
members: "اعضا",
|
||||
detail: "جزئیات",
|
||||
permission: "مجوزها",
|
||||
media: "رسانه ها",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
|
||||
@@ -105,6 +114,20 @@ const fa: SynapseTranslationMessages = {
|
||||
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانههایی که در مخازن رسانه خارجی آپلود شدهاند تأثیری نخواهد گذاشت.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "رسانههای از راه دور",
|
||||
fields: {
|
||||
before_ts: "آخرین دسترسی قبل از",
|
||||
},
|
||||
action: {
|
||||
send: "پاک کردن رسانههای از راه دور",
|
||||
send_success: "درخواست پاک کردن رسانههای از راه دور ارسال شد.",
|
||||
send_failure: "درخواست برای پاک کردن رسانههای از راه دور با خطا مواجه شد.",
|
||||
},
|
||||
helper: {
|
||||
send: "این API کش رسانههای از راه دور را از دیسک سرور شما پاک میکند. این شامل هر گونه بندانگشتی محلی و نسخههای رسانههای دانلود شده میشود. این API بر رسانههای آپلود شده به مخزن رسانه سرور تأثیری نخواهد داشت.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "کاربر |||| کاربران",
|
||||
@@ -118,8 +141,12 @@ const fa: SynapseTranslationMessages = {
|
||||
is_guest: "مهمان",
|
||||
admin: "مدیر سرور",
|
||||
deactivated: "غیرفعال",
|
||||
locked: "قفل شده",
|
||||
suspended: "معلق",
|
||||
guests: "نمایش مهمانان",
|
||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||
show_locked: "نمایش کاربران قفل شده",
|
||||
show_suspended: "نمایش کاربران معلق",
|
||||
user_id: "جستجوی کاربر",
|
||||
displayname: "نام نمایشی",
|
||||
password: "رمز عبور",
|
||||
@@ -135,12 +162,40 @@ const fa: SynapseTranslationMessages = {
|
||||
},
|
||||
helper: {
|
||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||
create_password: "رمز عبور قوی و امنی را با استفاده از دکمه زیر ایجاد کنید.",
|
||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||
suspend: "کاربران معلق نمی توانند وارد شوند و پیام های آنها به دیگران نمایش داده نمی شود.",
|
||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
|
||||
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
|
||||
username_available: "نام کاربری موجود",
|
||||
},
|
||||
badge: {
|
||||
you: "شما",
|
||||
bot: "ربات",
|
||||
admin: "مدیر",
|
||||
support: "پشتیبان",
|
||||
regular: "کاربر عادی",
|
||||
system_managed: "مدیریت سیستم",
|
||||
},
|
||||
action: {
|
||||
erase: "پاک کردن اطلاعات کاربر",
|
||||
erase_avatar: "محو الصورة الرمزية",
|
||||
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
|
||||
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||
generate_password: "توليد رمز عبور",
|
||||
overwrite_title: "هشدار!",
|
||||
overwrite_content: "این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می خواهید کاربر موجود را بازنویسی کنید؟",
|
||||
overwrite_cancel: "انصراف",
|
||||
overwrite_confirm: "بازنویسی",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "پیام در ثانیه",
|
||||
messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود.",
|
||||
burst_count: "تعداد پیچیدگی",
|
||||
burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "اتاق |||| اتاق ها",
|
||||
@@ -163,6 +218,7 @@ const fa: SynapseTranslationMessages = {
|
||||
history_visibility: "مشاهده تاریخچه",
|
||||
topic: "موضوع",
|
||||
avatar: "آواتار",
|
||||
actions: "عملیات",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
@@ -192,7 +248,20 @@ const fa: SynapseTranslationMessages = {
|
||||
title: "حذف اتاق",
|
||||
content:
|
||||
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
|
||||
fields: {
|
||||
block: "حذف",
|
||||
},
|
||||
success: "اتاق با موفقیت حذف شد.",
|
||||
failure: "خطایی رخ داده است.",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "مدیر انتخاب کنید",
|
||||
title: "مدیر اتاق %{roomName} را انتخاب کنید",
|
||||
confirm: "مدیر انتخاب کنید",
|
||||
content: "کامل MXID کاربری را وارد کنید که به عنوان مدیر تنظیم شود.\nهشدار: برای این کار، اتاق باید حداقل یک اعضای محلی به عنوان مدیر داشته باشد.",
|
||||
success: "کاربر به عنوان مدیر اتاق تنظیم شد.",
|
||||
failure: "کاربر به عنوان مدیر اتاق تنظیم نشد. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -330,6 +399,18 @@ const fa: SynapseTranslationMessages = {
|
||||
sender: "فرستنده",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "رسانه ها",
|
||||
fields: {
|
||||
media_id: "شناسه رسانه",
|
||||
},
|
||||
helper: {
|
||||
info: "این یک لیست از رسانه ها است که در اتاق بارگذاری شده است. نمی توان رسانه ها را حذف کرد که در اتاق های خارجی بارگذاری شده اند.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
room_directory: {
|
||||
name: "راهنمای اتاق",
|
||||
fields: {
|
||||
|
@@ -7,16 +7,24 @@ const fr: SynapseTranslationMessages = {
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "URL du serveur d’accueil",
|
||||
welcome: "Bienvenue sur Synapse-admin",
|
||||
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",
|
||||
credentials: "Identifiants",
|
||||
access_token: "Jeton d'accès",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Vous utilisez un jeton d'accès Matrix existant.",
|
||||
content: "Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?",
|
||||
confirm: "Détruire la session",
|
||||
cancel: "Se déconnecter simplement du panneau d'administration",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||
tabs: { sso: "Authentification unique" },
|
||||
tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -24,6 +32,7 @@ const fr: SynapseTranslationMessages = {
|
||||
members: "Membres",
|
||||
detail: "Détails",
|
||||
permission: "Permissions",
|
||||
media: "Médias",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
|
||||
@@ -108,6 +117,20 @@ const fr: SynapseTranslationMessages = {
|
||||
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Médias distants",
|
||||
fields: {
|
||||
before_ts: "dernier accès avant",
|
||||
},
|
||||
action: {
|
||||
send: "Purger les médias distants",
|
||||
send_success: "La demande de purge des médias distants a été envoyée.",
|
||||
send_failure: "Une erreur est survenue lors de la demande de purge des médias distants.",
|
||||
},
|
||||
helper: {
|
||||
send: "Cette API purge le cache des médias distants du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléchargés dans le dépôt de médias du serveur.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Utilisateur |||| Utilisateurs",
|
||||
@@ -121,9 +144,12 @@ const fr: SynapseTranslationMessages = {
|
||||
is_guest: "Visiteur",
|
||||
admin: "Administrateur du serveur",
|
||||
locked: "Verrouillé",
|
||||
suspended: "Suspendu",
|
||||
deactivated: "Désactivé",
|
||||
guests: "Afficher les visiteurs",
|
||||
show_deactivated: "Afficher les utilisateurs désactivés",
|
||||
show_locked: "Afficher les utilisateurs verrouillés",
|
||||
show_suspended: "Afficher les utilisateurs suspendus",
|
||||
user_id: "Rechercher un utilisateur",
|
||||
displayname: "Nom d'affichage",
|
||||
password: "Mot de passe",
|
||||
@@ -137,12 +163,41 @@ const fr: SynapseTranslationMessages = {
|
||||
auth_provider: "Fournisseur d'identité",
|
||||
},
|
||||
helper: {
|
||||
password: "Changer le mot de passe déconnectera l'utilisateur de toutes les sessions.",
|
||||
create_password: "Générer un mot de passe fort et sécurisé en utilisant le bouton ci-dessous.",
|
||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||
suspend: "L'utilisateur sera suspendu jusqu'à ce que vous le réactiviez.",
|
||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||
erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.",
|
||||
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
|
||||
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
|
||||
username_available: "Nom d'utilisateur disponible",
|
||||
},
|
||||
badge: {
|
||||
you: "Vous",
|
||||
bot: "Bot",
|
||||
admin: "Admin",
|
||||
support: "Support",
|
||||
regular: "Utilisateur régulier",
|
||||
system_managed: "Géré par le système",
|
||||
},
|
||||
action: {
|
||||
erase: "Effacer les données de l'utilisateur",
|
||||
erase_avatar: "Effacer l'avatar",
|
||||
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
|
||||
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
|
||||
generate_password: "Générer un mot de passe",
|
||||
overwrite_title: "Attention !",
|
||||
overwrite_content: "Ce nom d'utilisateur est déjà pris. Êtes-vous sûr de vouloir écraser l'utilisateur existant ?",
|
||||
overwrite_cancel: "Annuler",
|
||||
overwrite_confirm: "Écraser",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messages par seconde",
|
||||
messages_per_second_text: "Le nombre d'actions que l'utilisateur peut effectuer par seconde.",
|
||||
burst_count: "Compteur de pics",
|
||||
burst_count_text: "Le nombre d'actions que l'utilisateur peut effectuer avant d'être limité.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Salon |||| Salons",
|
||||
@@ -165,6 +220,7 @@ const fr: SynapseTranslationMessages = {
|
||||
history_visibility: "Visibilité de l'historique",
|
||||
topic: "Sujet",
|
||||
avatar: "Avatar",
|
||||
actions: "Actions",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
@@ -194,7 +250,20 @@ const fr: SynapseTranslationMessages = {
|
||||
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 !",
|
||||
fields: {
|
||||
block: "Bloquer et empêcher les utilisateurs de rejoindre la salle",
|
||||
},
|
||||
success: "Salle/s supprimées avec succès.",
|
||||
failure: "La/les salle/s n'ont pas pu être supprimées.",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Assigner un administrateur",
|
||||
title: "Assigner un administrateur au salon %{roomName}",
|
||||
confirm: "Assigner un administrateur",
|
||||
content: "Entrez la MXID complète de l'utilisateur qui sera désigné comme administrateur.\nAttention : pour que cela fonctionne, la salle doit avoir au moins un membre local en tant qu'administrateur.",
|
||||
success: "L'utilisateur a été désigné comme administrateur de la salle.",
|
||||
failure: "L'utilisateur n'a pas pu être désigné comme administrateur de la salle. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -332,6 +401,18 @@ const fr: SynapseTranslationMessages = {
|
||||
sender: "Expéditeur",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "Médias",
|
||||
fields: {
|
||||
media_id: "Identifiant du média",
|
||||
},
|
||||
helper: {
|
||||
info: "Cette liste contient les médias qui ont été téléchargés dans le salon. Il n'est pas possible de supprimer les médias qui ont été téléversés dans des dépôts de médias externes.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
room_directory: {
|
||||
name: "Répertoire des salons",
|
||||
fields: {
|
||||
|
84
src/i18n/index.d.ts
vendored
@@ -11,10 +11,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
protocol_error: string;
|
||||
url_error: string;
|
||||
sso_sign_in: string;
|
||||
credentials: string;
|
||||
access_token: string;
|
||||
logout_acces_token_dialog: {
|
||||
title: string;
|
||||
content: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
};
|
||||
};
|
||||
users: {
|
||||
invalid_user_id: string;
|
||||
tabs: { sso: string };
|
||||
tabs: { sso: string; experimental: string; limits: string; };
|
||||
};
|
||||
rooms: {
|
||||
details?: string; // TODO: fa, fr, it, zh
|
||||
@@ -23,6 +31,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
members: string;
|
||||
detail: string;
|
||||
permission: string;
|
||||
media: string;
|
||||
};
|
||||
};
|
||||
reports: { tabs: { basic: string; detail: string } };
|
||||
@@ -103,6 +112,20 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
send: string;
|
||||
};
|
||||
};
|
||||
purge_remote_media: {
|
||||
name: string;
|
||||
fields: {
|
||||
before_ts: string;
|
||||
};
|
||||
action: {
|
||||
send: string;
|
||||
send_success: string;
|
||||
send_failure: string;
|
||||
};
|
||||
helper: {
|
||||
send: string;
|
||||
};
|
||||
};
|
||||
resources: {
|
||||
users: {
|
||||
name: string;
|
||||
@@ -116,10 +139,13 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
is_guest: string;
|
||||
admin: string;
|
||||
locked?: string; // TODO: fa, zh
|
||||
suspended: string;
|
||||
deactivated: string;
|
||||
erased?: string; // TODO: fa, fr, it, zh
|
||||
guests: string;
|
||||
show_deactivated: string;
|
||||
show_locked?: string; // TODO: de, fa, fr, it, zh
|
||||
show_suspended: string;
|
||||
user_id: string;
|
||||
displayname: string;
|
||||
password: string;
|
||||
@@ -134,12 +160,40 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
user_type?: string;
|
||||
};
|
||||
helper: {
|
||||
password?: string;
|
||||
password: string;
|
||||
create_password: string;
|
||||
deactivate: string;
|
||||
suspend: string;
|
||||
erase: string;
|
||||
erase_text: string;
|
||||
erase_admin_error: string;
|
||||
modify_managed_user_error: string;
|
||||
username_available: string;
|
||||
};
|
||||
action: {
|
||||
erase: string;
|
||||
erase_avatar: string;
|
||||
delete_media: string;
|
||||
redact_events: string;
|
||||
generate_password: string;
|
||||
overwrite_title: string;
|
||||
overwrite_content: string;
|
||||
overwrite_cancel: string;
|
||||
overwrite_confirm: string;
|
||||
};
|
||||
badge: {
|
||||
you: string;
|
||||
bot: string;
|
||||
admin: string;
|
||||
support: string;
|
||||
regular: string;
|
||||
system_managed: string;
|
||||
}
|
||||
limits: {
|
||||
messages_per_second: string;
|
||||
messages_per_second_text: string;
|
||||
burst_count: string;
|
||||
burst_count_text: string;
|
||||
};
|
||||
};
|
||||
rooms: {
|
||||
@@ -163,6 +217,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
history_visibility: string;
|
||||
topic?: string;
|
||||
avatar?: string;
|
||||
actions: string;
|
||||
};
|
||||
helper?: {
|
||||
forward_extremities: string;
|
||||
@@ -190,6 +245,19 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
erase: {
|
||||
title: string;
|
||||
content: string;
|
||||
fields: {
|
||||
block: string;
|
||||
},
|
||||
success: string;
|
||||
failure: string;
|
||||
};
|
||||
make_admin: {
|
||||
assign_admin: string;
|
||||
title: string;
|
||||
confirm: string;
|
||||
content: string;
|
||||
success: string;
|
||||
failure: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -343,6 +411,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
sender: string;
|
||||
};
|
||||
};
|
||||
room_media?: {
|
||||
name: string;
|
||||
fields: {
|
||||
media_id: string;
|
||||
};
|
||||
helper: {
|
||||
info: string;
|
||||
};
|
||||
action: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
room_directory?: {
|
||||
name: string;
|
||||
fields: {
|
||||
|
471
src/i18n/it.ts
@@ -7,16 +7,24 @@ const it: SynapseTranslationMessages = {
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "URL dell'homeserver",
|
||||
welcome: "Benvenuto in Synapse-admin",
|
||||
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",
|
||||
credentials: "Credenziali",
|
||||
access_token: "Token di accesso",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Stai utilizzando un token di accesso Matrix esistente.",
|
||||
content: "Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?",
|
||||
confirm: "Distruggi sessione",
|
||||
cancel: "Disconnetti solo dal pannello di amministrazione",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "ID utente non valido su questo homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -24,6 +32,7 @@ const it: SynapseTranslationMessages = {
|
||||
members: "Membro",
|
||||
detail: "Dettagli",
|
||||
permission: "Permessi",
|
||||
media: "Media",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Semplice", detail: "Dettagli" } },
|
||||
@@ -105,6 +114,20 @@ const it: SynapseTranslationMessages = {
|
||||
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Media Remoti",
|
||||
fields: {
|
||||
before_ts: "ultimo accesso prima di",
|
||||
},
|
||||
action: {
|
||||
send: "Elimina media remoti",
|
||||
send_success: "La richiesta per eliminare i media remoti è stata inviata.",
|
||||
send_failure: "Si è verificato un errore con la richiesta di eliminazione dei media remoti.",
|
||||
},
|
||||
helper: {
|
||||
send: "Questa API elimina la cache dei media remoti dal disco del tuo server. Questo include qualsiasi miniatura locale e copie di media scaricati. Questa API non influirà sui media che sono stati caricati nel repository multimediale del server.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Utente |||| Utenti",
|
||||
@@ -118,9 +141,12 @@ const it: SynapseTranslationMessages = {
|
||||
is_guest: "Ospite",
|
||||
admin: "Amministratore",
|
||||
locked: "Bloccato",
|
||||
suspended: "Sospeso",
|
||||
deactivated: "Disattivato",
|
||||
guests: "Mostra gli ospiti",
|
||||
show_deactivated: "Mostra gli utenti disattivati",
|
||||
show_locked: "Mostra gli utenti bloccati",
|
||||
show_suspended: "Mostra gli utenti sospesi",
|
||||
user_id: "Cerca utente",
|
||||
displayname: "Nickname",
|
||||
password: "Password",
|
||||
@@ -136,12 +162,40 @@ const it: SynapseTranslationMessages = {
|
||||
},
|
||||
helper: {
|
||||
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
||||
create_password: "Genera una password forte e sicura utilizzando il pulsante sottostante.",
|
||||
deactivate: "Devi fornire una password per riattivare l'account.",
|
||||
suspend: "Sospendi l'utente",
|
||||
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
||||
erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
|
||||
erase_admin_error: "Non è consentito eliminare il proprio utente.",
|
||||
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
|
||||
username_available: "Nome utente disponibile",
|
||||
},
|
||||
badge: {
|
||||
you: "Tu",
|
||||
bot: "Bot",
|
||||
admin: "Amministratore",
|
||||
support: "Supporto",
|
||||
regular: "Utente normale",
|
||||
system_managed: "Gestito dal sistema",
|
||||
},
|
||||
action: {
|
||||
erase: "Cancella i dati dell'utente",
|
||||
erase_avatar: "Cancella l'avatar dell'utente",
|
||||
delete_media: "Elimina tutti i media caricati dall'utente(-s)",
|
||||
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
|
||||
generate_password: "Genera password",
|
||||
overwrite_title: "Attenzione!",
|
||||
overwrite_content: "Questo nome utente è già stato utilizzato. Sei sicuro di voler sovrascrivere l'utente esistente?",
|
||||
overwrite_cancel: "Annulla",
|
||||
overwrite_confirm: "Sovrascrivi",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messaggi al secondo",
|
||||
messages_per_second_text: "Il numero di azioni che l'utente può eseguire al secondo.",
|
||||
burst_count: "Burst-conteggio",
|
||||
burst_count_text: "Il numero di azioni che l'utente può eseguire prima di essere limitato.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Stanza |||| Stanze",
|
||||
@@ -164,214 +218,235 @@ const it: SynapseTranslationMessages = {
|
||||
history_visibility: "Visibilità temporale",
|
||||
topic: "Topic",
|
||||
avatar: "Avatar",
|
||||
actions: "Azioni",
|
||||
},
|
||||
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: {
|
||||
"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",
|
||||
public: "Pubblica",
|
||||
knock: "Bussa",
|
||||
invite: "Invita",
|
||||
private: "Privata",
|
||||
},
|
||||
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!",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
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",
|
||||
action: {
|
||||
erase: {
|
||||
title: "Cancella stanza",
|
||||
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.",
|
||||
"Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Assegna un amministratore",
|
||||
title: "Assegna un amministratore alla stanza %{roomName}",
|
||||
confirm: "Assegna un amministratore",
|
||||
content: "Inserisci la MXID completa dell'utente che sarà designato come amministratore.\nAttenzione: per questo funzionare, la stanza deve avere almeno un membro locale come amministratore.",
|
||||
success: "L'utente è stato designato come amministratore della stanza.",
|
||||
failure: "L'utente non può essere designato come amministratore della stanza. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
name: "Evento segnalato |||| Eventi segnalati",
|
||||
fields: {
|
||||
id: "ID",
|
||||
received_ts: "Orario del report",
|
||||
user_id: "richiedente",
|
||||
name: "nome della stanza",
|
||||
score: "punteggio",
|
||||
reason: "ragione",
|
||||
event_id: "ID dell'evento",
|
||||
event_json: {
|
||||
origin: "server di origine",
|
||||
origin_server_ts: "ora dell'invio",
|
||||
type: "tipo di evento",
|
||||
content: {
|
||||
msgtype: "tipo di contenuto",
|
||||
body: "contenuto",
|
||||
format: "formato",
|
||||
formatted_body: "contenuto formattato",
|
||||
algorithm: "algoritmo",
|
||||
},
|
||||
},
|
||||
},
|
||||
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" },
|
||||
},
|
||||
connections: {
|
||||
name: "Connessioni",
|
||||
fields: {
|
||||
last_seen: "Data",
|
||||
ip: "Indirizzo IP",
|
||||
user_agent: "agente utente",
|
||||
},
|
||||
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." },
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
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_media: {
|
||||
name: "Media",
|
||||
fields: {
|
||||
media_id: "ID Media",
|
||||
},
|
||||
helper: {
|
||||
info: "Questo è un elenco dei media caricati nella stanza. Non è possibile eliminare i media caricati su repository esterni.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
119
src/i18n/ru.ts
@@ -2,22 +2,55 @@ import russianMessages from "ra-language-russian";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
const fixedRussianMessages = {
|
||||
...russianMessages,
|
||||
ra: {
|
||||
...russianMessages.ra,
|
||||
navigation: {
|
||||
...russianMessages.ra.navigation,
|
||||
no_filtered_results: "Нет результатов",
|
||||
clear_filters: "Все фильтры сбросить",
|
||||
},
|
||||
page: {
|
||||
...russianMessages.ra.page,
|
||||
empty: "Пусто",
|
||||
access_denied: "Доступ запрещен",
|
||||
authentication_error: "Ошибка аутентификации",
|
||||
},
|
||||
message: {
|
||||
...russianMessages.ra.message,
|
||||
access_denied:
|
||||
"У вас нет прав доступа к этой странице.",
|
||||
authentication_error:
|
||||
"Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
...fixedRussianMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Адрес домашнего сервера",
|
||||
welcome: "Добро пожаловать в Synapse-admin",
|
||||
welcome: "Добро пожаловать в Synapse Admin",
|
||||
server_version: "Версия Synapse",
|
||||
supports_specs: "поддерживает спецификации Matrix",
|
||||
username_error: "Пожалуйста, укажите полный ID пользователя: '@user:domain'",
|
||||
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
|
||||
url_error: "Неверный адрес сервера Matrix",
|
||||
sso_sign_in: "Вход через SSO",
|
||||
credentials: "Учетные данные",
|
||||
access_token: "Токен доступа",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Вы используете существующий токен доступа Matrix.",
|
||||
content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?",
|
||||
confirm: "Завершить сессию",
|
||||
cancel: "Просто выйти из панели администрирования",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Данные комнаты",
|
||||
@@ -26,6 +59,7 @@ const ru: SynapseTranslationMessages = {
|
||||
members: "Участники",
|
||||
detail: "Подробности",
|
||||
permission: "Права доступа",
|
||||
media: "Медиа",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "Основные", detail: "Подробности" } },
|
||||
@@ -79,7 +113,7 @@ const ru: SynapseTranslationMessages = {
|
||||
header: "Загрузить CSV файл",
|
||||
explanation:
|
||||
"Здесь вы можете загрузить файл со значениями, разделёнными запятыми, которые будут использованы для создания или обновления данных пользователей. \
|
||||
В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ",
|
||||
В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ",
|
||||
},
|
||||
startImport: {
|
||||
simulate_only: "Только симулировать",
|
||||
@@ -116,6 +150,20 @@ const ru: SynapseTranslationMessages = {
|
||||
Данный API не затрагивает файлы, загруженные во внешние хранилища.",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "Внешние медиа",
|
||||
fields: {
|
||||
before_ts: "последний доступ до",
|
||||
},
|
||||
action: {
|
||||
send: "Очистить внешние медиа",
|
||||
send_success: "Запрос на очистку внешних медиа был отправлен.",
|
||||
send_failure: "Произошла ошибка при запросе очистки внешних медиа.",
|
||||
},
|
||||
helper: {
|
||||
send: "Этот API очищает кэш внешних медиа с диска вашего сервера. Это включает любые локальные миниатюры и копии загруженных медиа. Этот API не повлияет на медиа, которые были загружены в собственное медиа-хранилище сервера.",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "Пользователь |||| Пользователи",
|
||||
@@ -129,10 +177,13 @@ const ru: SynapseTranslationMessages = {
|
||||
is_guest: "Гость",
|
||||
admin: "Администратор сервера",
|
||||
locked: "Заблокирован",
|
||||
suspended: "Приостановлен",
|
||||
deactivated: "Деактивирован",
|
||||
erased: "Удалён",
|
||||
guests: "Показывать гостей",
|
||||
show_deactivated: "Показывать деактивированных",
|
||||
show_locked: "Показывать заблокированных",
|
||||
show_suspended: "Показывать приостановленных",
|
||||
user_id: "Поиск пользователя",
|
||||
displayname: "Отображаемое имя",
|
||||
password: "Пароль",
|
||||
@@ -148,12 +199,40 @@ const ru: SynapseTranslationMessages = {
|
||||
},
|
||||
helper: {
|
||||
password: "Смена пароля завершит все сессии пользователя.",
|
||||
create_password: "Сгенерировать надёжный и безопасный пароль, используя кнопку ниже.",
|
||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||
suspend: "Приостановка учётной записи означает, что пользователь не сможет войти в свою учётную запись, пока она не будет снова активирована.",
|
||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
|
||||
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
|
||||
username_available: "Имя пользователя доступно",
|
||||
},
|
||||
badge: {
|
||||
you: "Вы",
|
||||
bot: "Бот",
|
||||
admin: "Админ",
|
||||
support: "Поддержка",
|
||||
regular: "Обычный пользователь",
|
||||
system_managed: "Управляемый системой",
|
||||
},
|
||||
action: {
|
||||
erase: "Удалить данные пользователя",
|
||||
erase_avatar: "Удалить аватар",
|
||||
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
|
||||
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
|
||||
generate_password: "Сгенерировать пароль",
|
||||
overwrite_title: "Предупреждение!",
|
||||
overwrite_content: "Это имя пользователя уже занято. Вы уверены, что хотите перезаписать существующего пользователя?",
|
||||
overwrite_cancel: "Отмена",
|
||||
overwrite_confirm: "Перезаписать",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Сообщений в секунду",
|
||||
messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду.",
|
||||
burst_count: "Burst-счётчик",
|
||||
burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Комната |||| Комнаты",
|
||||
@@ -176,13 +255,14 @@ const ru: SynapseTranslationMessages = {
|
||||
history_visibility: "Видимость истории",
|
||||
topic: "Тема",
|
||||
avatar: "Аватар",
|
||||
actions: "Действия",
|
||||
},
|
||||
helper: {
|
||||
forward_extremities:
|
||||
"Оконечности — это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \
|
||||
Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \
|
||||
Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \
|
||||
Если в комнате >10 оконечностей, стоит найти комнату-виновника и попробовать удалить их с помощью SQL-запросов из #1760.",
|
||||
Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \
|
||||
Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \
|
||||
Если в комнате >10 оконечностей, стоит найти комнату-виновника и попробовать удалить их с помощью SQL-запросов из #1760.",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
@@ -208,7 +288,20 @@ const ru: SynapseTranslationMessages = {
|
||||
title: "Удалить комнату",
|
||||
content:
|
||||
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
||||
fields: {
|
||||
block: "Заблокировать и запретить пользователям присоединяться к комнате",
|
||||
},
|
||||
success: "Комната/ы успешно удалены",
|
||||
failure: "Комната/ы не могут быть удалены.",
|
||||
},
|
||||
make_admin: {
|
||||
assign_admin: "Назначить администратора",
|
||||
title: "Назначить администратора комнате %{roomName}",
|
||||
confirm: "Назначить администратора",
|
||||
content: "Введите полную MXID пользователя, которого нужно назначить администратором.\nПредупреждение: для этого должен быть назначен хотя бы один локальный участник в качестве администратора.",
|
||||
success: "Пользователь назначен администратором комнаты.",
|
||||
failure: "Пользователь не может быть назначен администратором комнаты. %{errMsg}",
|
||||
}
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
@@ -359,6 +452,18 @@ const ru: SynapseTranslationMessages = {
|
||||
sender: "Отправитель",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "Медиа",
|
||||
fields: {
|
||||
media_id: "ID медиа",
|
||||
},
|
||||
helper: {
|
||||
info: "Это список медиа, которые были загружены в комнату. Невозможно удалить медиа, которые были загружены в внешние медиа-репозитории.",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
room_directory: {
|
||||
name: "Каталог комнат",
|
||||
fields: {
|
||||
|
105
src/i18n/zh.ts
@@ -2,21 +2,57 @@ import chineseMessages from "@haxqer/ra-language-chinese";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
const fixedChineseMessages = {
|
||||
...chineseMessages,
|
||||
ra: {
|
||||
...chineseMessages.ra,
|
||||
navigation: {
|
||||
...chineseMessages.ra.navigation,
|
||||
no_filtered_results: "没有结果",
|
||||
clear_filters: "清除所有过滤器",
|
||||
},
|
||||
action: {
|
||||
...chineseMessages.ra.action,
|
||||
update_application: "Anwendung aktualisieren",
|
||||
},
|
||||
page: {
|
||||
...chineseMessages.ra.page,
|
||||
access_denied: "拒绝访问",
|
||||
authentication_error: "认证错误",
|
||||
},
|
||||
message: {
|
||||
...chineseMessages.ra.message,
|
||||
access_denied:
|
||||
"您没有访问此页面的权限。",
|
||||
authentication_error:
|
||||
"身份验证服务器返回错误,无法验证您的凭据。",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
...fixedChineseMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "服务器 URL",
|
||||
welcome: "欢迎来到 Synapse-admin",
|
||||
welcome: "欢迎来到 Synapse Admin",
|
||||
server_version: "Synapse 版本",
|
||||
username_error: "请输入完整有效的用户 ID: '@user:domain'",
|
||||
protocol_error: "URL 需要以'http://'或'https://'作为起始",
|
||||
url_error: "不是一个有效的 Matrix 服务器地址",
|
||||
sso_sign_in: "使用 SSO 登录",
|
||||
credentials: "凭证",
|
||||
access_token: "访问令牌",
|
||||
logout_acces_token_dialog: {
|
||||
title: "您正在使用现有的 Matrix 访问令牌。",
|
||||
content: "您想销毁此会话(可能在其他地方使用,例如在 Matrix 客户端中)还是仅从管理面板退出?",
|
||||
confirm: "销毁会话",
|
||||
cancel: "仅从管理面板退出",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "实验性", limits: "限制" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -24,6 +60,7 @@ const zh: SynapseTranslationMessages = {
|
||||
members: "成员",
|
||||
detail: "细节",
|
||||
permission: "权限",
|
||||
media: "媒体",
|
||||
},
|
||||
},
|
||||
reports: { tabs: { basic: "基本", detail: "细节" } },
|
||||
@@ -105,6 +142,20 @@ const zh: SynapseTranslationMessages = {
|
||||
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
|
||||
},
|
||||
},
|
||||
purge_remote_media: {
|
||||
name: "远程媒体",
|
||||
fields: {
|
||||
before_ts: "最后访问于之前",
|
||||
},
|
||||
action: {
|
||||
send: "清除远程媒体",
|
||||
send_success: "远程媒体清除请求已发送。",
|
||||
send_failure: "发生错误,远程媒体清除请求未成功。",
|
||||
},
|
||||
helper: {
|
||||
send: "此API清除您服务器磁盘上的远程媒体缓存。这包括任何本地缩略图和下载的媒体副本。此API不会影响已经上传到服务器媒体存储库的媒体。",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
users: {
|
||||
name: "用户",
|
||||
@@ -117,9 +168,13 @@ const zh: SynapseTranslationMessages = {
|
||||
name: "用户名",
|
||||
is_guest: "访客",
|
||||
admin: "服务器管理员",
|
||||
locked: "锁定",
|
||||
deactivated: "被禁用",
|
||||
suspended: "被锁定",
|
||||
guests: "显示访客",
|
||||
show_deactivated: "显示被禁用的账户",
|
||||
show_locked: "显示被锁定的账户",
|
||||
show_suspended: "显示被锁定的账户",
|
||||
user_id: "搜索用户",
|
||||
displayname: "显示名字",
|
||||
password: "密码",
|
||||
@@ -132,12 +187,41 @@ const zh: SynapseTranslationMessages = {
|
||||
consent_version: "协议版本",
|
||||
},
|
||||
helper: {
|
||||
password: "更改密码会使用户注销所有会话。",
|
||||
create_password: "使用下面的按钮生成一个强大和安全的密码。",
|
||||
deactivate: "您必须提供一串密码来激活账户。",
|
||||
suspend: "您必须提供一串密码来暂停账户。",
|
||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
|
||||
erase_admin_error: "不允许删除自己的用户",
|
||||
modify_managed_user_error: "不允许修改系统管理的用户。",
|
||||
username_available: "用户名可用",
|
||||
},
|
||||
badge: {
|
||||
you: "您",
|
||||
bot: "机器人",
|
||||
admin: "管理员",
|
||||
support: "支持",
|
||||
regular: "普通用户",
|
||||
system_managed: "系统管理",
|
||||
},
|
||||
action: {
|
||||
erase: "抹除用户信息",
|
||||
erase_avatar: "抹掉头像",
|
||||
delete_media: "删除用户上传的所有媒体",
|
||||
redact_events: "重新编辑用户(-s)发送的所有事件",
|
||||
generate_password: "生成密码",
|
||||
overwrite_title: "警告!",
|
||||
overwrite_content: "这个用户名已经被占用。您确定要覆盖现有的用户吗?",
|
||||
overwrite_cancel: "取消",
|
||||
overwrite_confirm: "覆盖",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "每秒消息数",
|
||||
messages_per_second_text: "每秒可以执行的操作数。",
|
||||
burst_count: "Burst-计数",
|
||||
burst_count_text: "在限制之前可以执行的操作数。",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "房间",
|
||||
@@ -157,6 +241,9 @@ const zh: SynapseTranslationMessages = {
|
||||
join_rules: "加入规则",
|
||||
guest_access: "访客访问",
|
||||
history_visibility: "历史可见性",
|
||||
topic: "主题",
|
||||
avatar: "头像",
|
||||
actions: "操作",
|
||||
},
|
||||
enums: {
|
||||
join_rules: {
|
||||
@@ -276,6 +363,18 @@ const zh: SynapseTranslationMessages = {
|
||||
media_length: "媒体文件长度",
|
||||
},
|
||||
},
|
||||
room_media: {
|
||||
name: "媒体",
|
||||
fields: {
|
||||
media_id: "媒体ID",
|
||||
},
|
||||
helper: {
|
||||
info: "这是上传到房间的媒体列表。无法删除上传到外部媒体存储库的媒体。",
|
||||
},
|
||||
action: {
|
||||
error: "%{errcode} (%{errstatus}) %{error}"
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
export default zh;
|
||||
|
@@ -2,17 +2,16 @@ import React from "react";
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import { AppContext } from "./AppContext";
|
||||
import { App } from "./App";
|
||||
import { AppContext } from "./Context";
|
||||
import { FetchConfig, GetConfig } from "./utils/config";
|
||||
|
||||
fetch("config.json")
|
||||
.then(res => res.json())
|
||||
.then(props =>
|
||||
createRoot(document.getElementById("root")).render(
|
||||
await FetchConfig();
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<AppContext.Provider value={props}>
|
||||
<AppContext.Provider value={GetConfig()}>
|
||||
<App />
|
||||
</AppContext.Provider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
);
|
||||
);
|
||||
|
@@ -4,18 +4,21 @@ import { render, screen } from "@testing-library/react";
|
||||
import { AdminContext } from "react-admin";
|
||||
|
||||
import LoginPage from "./LoginPage";
|
||||
import { AppContext } from "../AppContext";
|
||||
import { AppContext } from "../Context";
|
||||
import englishMessages from "../i18n/en";
|
||||
|
||||
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
||||
import { act } from "@testing-library/react";
|
||||
|
||||
describe("LoginForm", () => {
|
||||
it("renders with no restriction to homeserver", () => {
|
||||
render(
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
);
|
||||
it("renders with no restriction to homeserver", async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
);
|
||||
});
|
||||
|
||||
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
||||
screen.getByRole("combobox", { name: "" });
|
||||
@@ -30,7 +33,9 @@ describe("LoginForm", () => {
|
||||
|
||||
it("renders with single restricted homeserver", () => {
|
||||
render(
|
||||
<AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com" }}>
|
||||
<AppContext.Provider
|
||||
value={{ restrictBaseUrl: "https://matrix.example.com", asManagedUsers: [], menu: [] }}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
@@ -53,6 +58,8 @@ describe("LoginForm", () => {
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"],
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
}}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography } from "@mui/material";
|
||||
import {
|
||||
Form,
|
||||
FormDataConsumer,
|
||||
@@ -17,8 +15,8 @@ import {
|
||||
useLocales,
|
||||
} from "react-admin";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useAppContext } from "../AppContext";
|
||||
import LoginFormBox from "../components/LoginFormBox";
|
||||
import { useAppContext } from "../Context";
|
||||
import {
|
||||
getServerVersion,
|
||||
getSupportedFeatures,
|
||||
@@ -26,86 +24,43 @@ import {
|
||||
getWellKnownUrl,
|
||||
isValidBaseUrl,
|
||||
splitMxid,
|
||||
} from "../synapse/synapse";
|
||||
import storage from "../storage";
|
||||
} from "../synapse/matrix";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
||||
export type LoginMethod = "credentials" | "accessToken";
|
||||
|
||||
const LoginPage = () => {
|
||||
const login = useLogin();
|
||||
const notify = useNotify();
|
||||
const { restrictBaseUrl } = useAppContext();
|
||||
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
|
||||
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
|
||||
const allowSingleBaseUrl = typeof restrictBaseUrl === "string" && restrictBaseUrl !== "";
|
||||
const allowMultipleBaseUrls =
|
||||
Array.isArray(restrictBaseUrl) &&
|
||||
restrictBaseUrl.length > 0 &&
|
||||
restrictBaseUrl[0] !== "" &&
|
||||
restrictBaseUrl[0] !== null;
|
||||
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supportPassAuth, setSupportPassAuth] = useState(true);
|
||||
const [locale, setLocale] = useLocaleState();
|
||||
const locales = useLocales();
|
||||
const translate = useTranslate();
|
||||
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url");
|
||||
const base_url = allowSingleBaseUrl ? restrictBaseUrl : localStorage.getItem("base_url");
|
||||
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
||||
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = storage.getItem("sso_base_url");
|
||||
storage.removeItem("sso_base_url");
|
||||
const baseUrl = localStorage.getItem("sso_base_url");
|
||||
localStorage.removeItem("sso_base_url");
|
||||
if (baseUrl) {
|
||||
const auth = {
|
||||
base_url: baseUrl,
|
||||
@@ -127,7 +82,7 @@ const LoginPage = () => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [loginToken]);
|
||||
|
||||
const validateBaseUrl = value => {
|
||||
if (!value.match(/^(http|https):\/\//)) {
|
||||
@@ -141,6 +96,9 @@ const LoginPage = () => {
|
||||
|
||||
const handleSubmit = auth => {
|
||||
setLoading(true);
|
||||
const cleanUrl = window.location.href.replace(window.location.search, "");
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
|
||||
login(auth).catch(error => {
|
||||
setLoading(false);
|
||||
notify(
|
||||
@@ -155,7 +113,7 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
const handleSSO = () => {
|
||||
storage.setItem("sso_base_url", ssoBaseUrl);
|
||||
localStorage.setItem("sso_base_url", ssoBaseUrl);
|
||||
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
|
||||
window.location.href
|
||||
)}`;
|
||||
@@ -168,7 +126,9 @@ const LoginPage = () => {
|
||||
const [matrixVersions, setMatrixVersions] = useState("");
|
||||
|
||||
const handleUsernameChange = () => {
|
||||
if (formData.base_url || allowSingleBaseUrl) return;
|
||||
if (formData.base_url || allowSingleBaseUrl) {
|
||||
return;
|
||||
}
|
||||
// check if username is a full qualified userId then set base_url accordingly
|
||||
const domain = splitMxid(formData.username)?.domain;
|
||||
if (domain) {
|
||||
@@ -180,6 +140,9 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.base_url) {
|
||||
form.setValue("base_url", "");
|
||||
}
|
||||
if (formData.base_url === "" && allowMultipleBaseUrls) {
|
||||
form.setValue("base_url", restrictBaseUrl[0]);
|
||||
}
|
||||
@@ -206,43 +169,93 @@ const LoginPage = () => {
|
||||
.catch(() => setSSOBaseUrl(""));
|
||||
}, [formData.base_url, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hostname = window.location.hostname;
|
||||
const username = params.get("username");
|
||||
const password = params.get("password");
|
||||
const accessToken = params.get("accessToken");
|
||||
let serverURL = params.get("server");
|
||||
if (username) {
|
||||
form.setValue("username", username);
|
||||
}
|
||||
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
if (password) {
|
||||
form.setValue("password", password);
|
||||
}
|
||||
if (accessToken) {
|
||||
setLoginMethod("accessToken");
|
||||
form.setValue("accessToken", accessToken);
|
||||
}
|
||||
}
|
||||
if (serverURL) {
|
||||
const isFullUrl = serverURL.match(/^(http|https):\/\//);
|
||||
if (!isFullUrl) {
|
||||
serverURL = `https://${serverURL}`;
|
||||
}
|
||||
form.setValue("base_url", serverURL);
|
||||
}
|
||||
}, [window.location.search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<TextInput
|
||||
autoFocus
|
||||
source="username"
|
||||
label="ra.auth.username"
|
||||
autoComplete="username"
|
||||
disabled={loading || !supportPassAuth}
|
||||
onBlur={handleUsernameChange}
|
||||
resettable
|
||||
fullWidth
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<PasswordInput
|
||||
source="password"
|
||||
label="ra.auth.password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={loading || !supportPassAuth}
|
||||
resettable
|
||||
fullWidth
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={loginMethod}
|
||||
onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
centered
|
||||
>
|
||||
<Tab label={translate("synapseadmin.auth.credentials")} value="credentials" />
|
||||
<Tab label={translate("synapseadmin.auth.access_token")} value="accessToken" />
|
||||
</Tabs>
|
||||
{loginMethod === "credentials" ? (
|
||||
<>
|
||||
<Box>
|
||||
<TextInput
|
||||
autoFocus
|
||||
source="username"
|
||||
label="ra.auth.username"
|
||||
autoComplete="username"
|
||||
onBlur={handleUsernameChange}
|
||||
resettable
|
||||
validate={required()}
|
||||
{...(loading || !supportPassAuth ? { disabled: true } : {})}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<PasswordInput
|
||||
source="password"
|
||||
label="ra.auth.password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...(loading || !supportPassAuth ? { disabled: true } : {})}
|
||||
resettable
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<TextInput
|
||||
source="accessToken"
|
||||
label="synapseadmin.auth.access_token"
|
||||
{...(loading ? { disabled: true } : {})}
|
||||
resettable
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<TextInput
|
||||
source="base_url"
|
||||
label="synapseadmin.auth.base_url"
|
||||
select={allowMultipleBaseUrls}
|
||||
autoComplete="url"
|
||||
disabled={loading}
|
||||
{...(loading ? { disabled: true } : {})}
|
||||
readOnly={allowSingleBaseUrl}
|
||||
resettable={allowAnyBaseUrl}
|
||||
fullWidth
|
||||
validate={[required(), validateBaseUrl]}
|
||||
>
|
||||
{allowMultipleBaseUrls &&
|
||||
@@ -261,23 +274,21 @@ const LoginPage = () => {
|
||||
|
||||
return (
|
||||
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
|
||||
<FormBox>
|
||||
<LoginFormBox>
|
||||
<Card className="card">
|
||||
<Box className="avatar">
|
||||
{loading ? (
|
||||
<CircularProgress size={25} thickness={2} />
|
||||
) : (
|
||||
<Avatar className="icon">
|
||||
<LockIcon />
|
||||
</Avatar>
|
||||
<Avatar sx={{ width: "120px", height: "120px" }} src="./images/logo.webp"/>
|
||||
)}
|
||||
</Box>
|
||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||
<Box className="form">
|
||||
<Select
|
||||
fullWidth
|
||||
value={locale}
|
||||
onChange={e => setLocale(e.target.value)}
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
className="select"
|
||||
>
|
||||
@@ -288,7 +299,7 @@ const LoginPage = () => {
|
||||
))}
|
||||
</Select>
|
||||
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
|
||||
<CardActions className="actions">
|
||||
{loginMethod === "credentials" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
@@ -307,11 +318,23 @@ const LoginPage = () => {
|
||||
>
|
||||
{translate("synapseadmin.auth.sso_sign_in")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</CardActions>}
|
||||
{loginMethod === "accessToken" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
</CardActions>}
|
||||
</Box>
|
||||
</Card>
|
||||
</FormBox>
|
||||
</LoginFormBox>
|
||||
<Notification />
|
||||
<Footer />
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
@@ -4,9 +4,11 @@ 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";
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import {
|
||||
Button,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
List,
|
||||
ListProps,
|
||||
@@ -21,22 +23,21 @@ import {
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
FunctionField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useDelete,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
DateFieldProps,
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import { get } from "lodash";
|
||||
|
||||
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
const destinationRowSx = (record: RaRecord) => ({
|
||||
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
|
||||
});
|
||||
|
||||
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
|
||||
|
||||
export const DestinationReconnectButton = () => {
|
||||
@@ -92,22 +93,46 @@ const DestinationTitle = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const RetryDateField = (props: DateFieldProps) => {
|
||||
const record = useRecordContext(props);
|
||||
if (props.source && get(record, props.source) === 0) {
|
||||
return <DateField {...props} record={{ ...record, [props.source]: null }} />;
|
||||
}
|
||||
return <DateField {...props} />;
|
||||
};
|
||||
|
||||
|
||||
const destinationFieldRender = (record: RaRecord) => {
|
||||
if (record.retry_last_ts > 0) {
|
||||
return (
|
||||
<>
|
||||
<ErrorIcon fontSize="inherit" color="error" sx={{verticalAlign: "middle"}}/>
|
||||
|
||||
{record.destination}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <> {record.destination} </>;
|
||||
}
|
||||
|
||||
export const DestinationList = (props: ListProps) => {
|
||||
const record = useRecordContext(props);
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
filters={destinationFilters}
|
||||
pagination={<DestinationPagination />}
|
||||
sort={{ field: "destination", order: "ASC" }}
|
||||
perPage={50}
|
||||
>
|
||||
<Datagrid rowSx={destinationRowSx} rowClick={id => `${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" />
|
||||
<DatagridConfigurable rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
||||
<FunctionField source="destination" render={destinationFieldRender} label="resources.destinations.fields.destination" />
|
||||
<DateField source="failure_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.failure_ts" />
|
||||
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} label="resources.destinations.fields.retry_last_ts" />
|
||||
<TextField source="retry_interval" label="resources.destinations.fields.retry_interval" />
|
||||
<TextField source="last_successful_stream_ordering" label="resources.destinations.fields.last_successful_stream_ordering" />
|
||||
<DestinationReconnectButton />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
Create,
|
||||
CreateProps,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
DateTimeInput,
|
||||
Edit,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
Toolbar,
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT, dateFormatter, dateParser } from "../components/date";
|
||||
import { DATE_FORMAT, dateFormatter, dateParser } from "../utils/date";
|
||||
|
||||
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
|
||||
const validateUsesAllowed = [number()];
|
||||
@@ -37,15 +38,15 @@ export const RegistrationTokenList = (props: ListProps) => (
|
||||
filters={registrationTokenFilters}
|
||||
filterDefaultValues={{ valid: true }}
|
||||
pagination={false}
|
||||
perPage={500}
|
||||
perPage={50}
|
||||
>
|
||||
<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>
|
||||
<DatagridConfigurable rowClick="edit">
|
||||
<TextField source="token" sortable={false} label="resources.registration_tokens.fields.token"/>
|
||||
<NumberField source="uses_allowed" sortable={false} label="resources.registration_tokens.fields.uses_allowed" />
|
||||
<NumberField source="pending" sortable={false} label="resources.registration_tokens.fields.pending" />
|
||||
<NumberField source="completed" sortable={false} label="resources.registration_tokens.fields.completed" />
|
||||
<DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} label="resources.registration_tokens.fields.expiry_time" />
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
@@ -3,6 +3,7 @@ import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import ReportIcon from "@mui/icons-material/Warning";
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
DeleteButton,
|
||||
List,
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { MXCField } from "../components/media";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import { ReportMediaContent } from "../components/media";
|
||||
|
||||
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
@@ -62,7 +63,7 @@ export const ReportShow = (props: ShowProps) => {
|
||||
<TextField source="event_json.content.msgtype" />
|
||||
<TextField source="event_json.content.body" />
|
||||
<TextField source="event_json.content.info.mimetype" />
|
||||
<MXCField source="event_json.content.url" />
|
||||
<ReportMediaContent source="event_json.content.url" />
|
||||
<TextField source="event_json.content.format" />
|
||||
<TextField source="event_json.content.formatted_body" />
|
||||
<TextField source="event_json.content.algorithm" />
|
||||
@@ -89,14 +90,14 @@ const ReportShowActions = () => {
|
||||
};
|
||||
|
||||
export const ReportList = (props: ListProps) => (
|
||||
<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 {...props} pagination={<ReportPagination />} perPage={50} sort={{ field: "received_ts", order: "DESC" }}>
|
||||
<DatagridConfigurable rowClick="show" bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} label="resources.reports.fields.id" />
|
||||
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} label="resources.reports.fields.received_ts" />
|
||||
<TextField sortable={false} source="user_id" label="resources.reports.fields.user_id" />
|
||||
<TextField sortable={false} source="name" label="resources.reports.fields.name" />
|
||||
<TextField sortable={false} source="score" label="resources.reports.fields.score" />
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
@@ -25,10 +25,9 @@ import {
|
||||
useRefresh,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
|
||||
import { MakeAdminBtn } from "./rooms";
|
||||
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
||||
|
||||
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
||||
@@ -70,27 +69,25 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
|
||||
const refresh = useRefresh();
|
||||
const unselectAllRooms = useUnselectAll("rooms");
|
||||
const dataProvider = useDataProvider();
|
||||
const { mutate, isLoading } = useMutation(
|
||||
() =>
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: () =>
|
||||
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",
|
||||
}),
|
||||
}
|
||||
);
|
||||
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}>
|
||||
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
|
||||
<RoomDirectoryIcon />
|
||||
</Button>
|
||||
);
|
||||
@@ -102,6 +99,10 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
|
||||
const refresh = useRefresh();
|
||||
const [create, { isLoading }] = useCreate();
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
create(
|
||||
"room_directory",
|
||||
@@ -134,7 +135,7 @@ const RoomDirectoryListActions = () => (
|
||||
);
|
||||
|
||||
export const RoomDirectoryList = () => (
|
||||
<List pagination={<RoomDirectoryPagination />} perPage={100} actions={<RoomDirectoryListActions />}>
|
||||
<List pagination={<RoomDirectoryPagination />} perPage={50} actions={<RoomDirectoryListActions />}>
|
||||
<DatagridConfigurable
|
||||
rowClick={id => "/rooms/" + id + "/show"}
|
||||
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
|
||||
@@ -142,7 +143,6 @@ export const RoomDirectoryList = () => (
|
||||
>
|
||||
<AvatarField
|
||||
source="avatar_src"
|
||||
sortable={false}
|
||||
sx={{ height: "40px", width: "40px" }}
|
||||
label="resources.rooms.fields.avatar"
|
||||
/>
|
||||
@@ -153,6 +153,7 @@ export const RoomDirectoryList = () => (
|
||||
<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" />
|
||||
<MakeAdminBtn />
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
@@ -7,15 +7,15 @@ import PageviewIcon from "@mui/icons-material/Pageview";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import RoomIcon from "@mui/icons-material/ViewList";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
BooleanField,
|
||||
BulkDeleteButton,
|
||||
DateField,
|
||||
WrapperField,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DeleteButton,
|
||||
ExportButton,
|
||||
FunctionField,
|
||||
List,
|
||||
@@ -32,19 +32,35 @@ import {
|
||||
ShowProps,
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
TextField as RaTextField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
useListContext,
|
||||
useNotify,
|
||||
DeleteButton,
|
||||
} from "react-admin";
|
||||
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
RoomDirectoryBulkUnpublishButton,
|
||||
RoomDirectoryBulkPublishButton,
|
||||
RoomDirectoryUnpublishButton,
|
||||
RoomDirectoryPublishButton,
|
||||
} from "./room_directory";
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||
import { MediaIDField } from "../components/media";
|
||||
import AvatarField from "../components/AvatarField";
|
||||
import { Room } from "../synapse/dataProvider";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { Confirm } from "react-admin";
|
||||
import { useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Alert from "@mui/material/Alert";
|
||||
|
||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
@@ -65,13 +81,17 @@ const RoomTitle = () => {
|
||||
|
||||
const RoomShowActions = () => {
|
||||
const record = useRecordContext();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
|
||||
// FIXME: refresh after (un)publish
|
||||
return (
|
||||
<TopToolbar>
|
||||
{publishButton}
|
||||
<DeleteButton
|
||||
mutationMode="pessimistic"
|
||||
<MakeAdminBtn />
|
||||
<DeleteRoomButton
|
||||
selectedIds={[record.id]}
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
/>
|
||||
@@ -79,34 +99,129 @@ const RoomShowActions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const MakeAdminBtn = () => {
|
||||
const record = useRecordContext() as Room;
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record.joined_local_members < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const ownMXID = localStorage.getItem("user_id") || "";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [userIdValue, setUserIdValue] = useState(ownMXID);
|
||||
const dataProvider = useDataProvider();
|
||||
const notify = useNotify();
|
||||
const translate = useTranslate();
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
const result = await dataProvider.makeRoomAdmin(record.room_id, userIdValue);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
notify("resources.rooms.action.make_admin.success", { type: "success" });
|
||||
setOpen(false);
|
||||
setUserIdValue("");
|
||||
},
|
||||
onError: (err) => {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
notify("resources.rooms.action.make_admin.failure", { type: "error", messageArgs: { errMsg: errorMessage } });
|
||||
setOpen(false);
|
||||
setUserIdValue("");
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUserIdValue(event.target.value);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
mutate();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
return (<>
|
||||
<Button size="small" onClick={(e) => { e.stopPropagation(); setOpen(true); }} disabled={isPending}>
|
||||
<PersonIcon /> {translate("resources.rooms.action.make_admin.assign_admin")}
|
||||
</Button>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
confirm="resources.rooms.action.make_admin.confirm"
|
||||
cancel="ra.action.cancel"
|
||||
title={translate("resources.rooms.action.make_admin.title", { roomName: record.name ? record.name : record.room_id })}
|
||||
content={<>
|
||||
<Typography sx={{ marginBottom: 2, whiteSpace: "pre-line"}}>{translate("resources.rooms.action.make_admin.content")}</Typography>
|
||||
<TextField
|
||||
type="text"
|
||||
variant="filled"
|
||||
value={userIdValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
label={"Matrix ID"}
|
||||
/>
|
||||
</>}
|
||||
/>
|
||||
</>);
|
||||
};
|
||||
|
||||
export const RoomShow = (props: ShowProps) => {
|
||||
const translate = useTranslate();
|
||||
const record = useRecordContext();
|
||||
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" />
|
||||
<AvatarField
|
||||
source="avatar"
|
||||
sx={{ height: "120px", width: "120px" }}
|
||||
label="resources.rooms.fields.avatar"
|
||||
/>
|
||||
<RaTextField source="room_id" />
|
||||
<RaTextField source="name" />
|
||||
<RaTextField source="topic" />
|
||||
<RaTextField source="canonical_alias" />
|
||||
<ReferenceField source="creator" reference="users">
|
||||
<TextField source="id" />
|
||||
<RaTextField 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")} />
|
||||
<RaTextField source="joined_members" />
|
||||
<RaTextField source="joined_local_members" />
|
||||
<RaTextField source="joined_local_devices" />
|
||||
<RaTextField source="state_events" />
|
||||
<RaTextField source="version" />
|
||||
<RaTextField source="encryption" emptyText={translate("resources.rooms.enums.unencrypted")} />
|
||||
</Tab>
|
||||
|
||||
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />} path="members">
|
||||
<MakeAdminBtn />
|
||||
<ReferenceManyField reference="room_members" target="room_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} label="resources.users.fields.id" />
|
||||
<Datagrid sx={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}>
|
||||
<RaTextField source="id" sortable={false} label="resources.users.fields.id" />
|
||||
<ReferenceField
|
||||
label="resources.users.fields.displayname"
|
||||
source="id"
|
||||
@@ -114,12 +229,22 @@ export const RoomShow = (props: ShowProps) => {
|
||||
sortable={false}
|
||||
link=""
|
||||
>
|
||||
<TextField source="displayname" sortable={false} />
|
||||
<RaTextField source="displayname" sortable={false} />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab label="synapseadmin.rooms.tabs.media" icon={<PermMediaIcon />} path="media">
|
||||
<Alert severity="warning">{translate("resources.room_media.helper.info")}</Alert>
|
||||
<ReferenceManyField reference="room_media" target="room_id" label={false}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<MediaIDField source="media_id" />
|
||||
<DeleteButton mutationMode="pessimistic" redirect={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
|
||||
<Tab label="synapseadmin.rooms.tabs.permission" icon={<VisibilityIcon />} path="permission">
|
||||
<BooleanField source="federatable" />
|
||||
<BooleanField source="public" />
|
||||
@@ -173,12 +298,12 @@ export const RoomShow = (props: ShowProps) => {
|
||||
|
||||
<Tab label={translate("resources.room_state.name", { smart_count: 2 })} icon={<EventIcon />} path="state">
|
||||
<ReferenceManyField reference="room_state" target="room_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="type" sortable={false} />
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<RaTextField source="type" sortable={false} />
|
||||
<DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} />
|
||||
<TextField source="content" sortable={false} />
|
||||
<FunctionField source="content" sortable={false} render={record => `${JSON.stringify(record.content, null, 2)}`} />
|
||||
<ReferenceField source="sender" reference="users" sortable={false}>
|
||||
<TextField source="id" />
|
||||
<RaTextField source="id" />
|
||||
</ReferenceField>
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
@@ -194,11 +319,11 @@ export const RoomShow = (props: ShowProps) => {
|
||||
{translate("resources.rooms.helper.forward_extremities")}
|
||||
</Box>
|
||||
<ReferenceManyField reference="forward_extremities" target="room_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} />
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<RaTextField 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} />
|
||||
<RaTextField source="state_group" sortable={false} />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</Tab>
|
||||
@@ -207,17 +332,20 @@ export const RoomShow = (props: ShowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RoomBulkActionButtons = () => (
|
||||
<>
|
||||
<RoomDirectoryBulkPublishButton />
|
||||
<RoomDirectoryBulkUnpublishButton />
|
||||
<BulkDeleteButton
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const RoomBulkActionButtons = () => {
|
||||
const record = useListContext();
|
||||
return (
|
||||
<>
|
||||
<RoomDirectoryBulkPublishButton />
|
||||
<RoomDirectoryBulkUnpublishButton />
|
||||
<DeleteRoomButton
|
||||
selectedIds={record.selectedIds}
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
||||
|
||||
@@ -238,30 +366,39 @@ export const RoomList = (props: ListProps) => {
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
filters={roomFilters}
|
||||
actions={<RoomListActions />}
|
||||
perPage={50}
|
||||
>
|
||||
<DatagridConfigurable
|
||||
rowClick="show"
|
||||
bulkActionButtons={<RoomBulkActionButtons />}
|
||||
omit={["joined_local_members", "state_events", "version", "federatable"]}
|
||||
>
|
||||
<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" />
|
||||
<ReferenceField reference="rooms" source="id" label="resources.users.fields.avatar" link={false} sortable={false}>
|
||||
<AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} />
|
||||
</ReferenceField>
|
||||
<WrapperField source="encryption" sortBy="is_encrypted" label="resources.rooms.fields.encryption">
|
||||
<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 },
|
||||
}}
|
||||
/>
|
||||
</WrapperField>
|
||||
<FunctionField source="name" render={record => record["name"] || record["canonical_alias"] || record["id"]} label="resources.rooms.fields.name" />
|
||||
<RaTextField source="joined_members" label="resources.rooms.fields.joined_members" />
|
||||
<RaTextField source="joined_local_members" label="resources.rooms.fields.joined_local_members" />
|
||||
<RaTextField source="state_events" label="resources.rooms.fields.state_events" />
|
||||
<RaTextField source="version" label="resources.rooms.fields.version" />
|
||||
<BooleanField source="federatable" label="resources.rooms.fields.federatable" />
|
||||
<BooleanField source="public" label="resources.rooms.fields.public" />
|
||||
<WrapperField label="resources.rooms.fields.actions">
|
||||
<MakeAdminBtn />
|
||||
</WrapperField>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import EqualizerIcon from "@mui/icons-material/Equalizer";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
ExportButton,
|
||||
List,
|
||||
ListProps,
|
||||
@@ -13,13 +14,14 @@ import {
|
||||
useListContext,
|
||||
} from "react-admin";
|
||||
|
||||
import { DeleteMediaButton } from "../components/media";
|
||||
import { DeleteMediaButton, PurgeRemoteMediaButton } from "../components/media";
|
||||
|
||||
const ListActions = () => {
|
||||
const { isLoading, total } = useListContext();
|
||||
return (
|
||||
<TopToolbar>
|
||||
<DeleteMediaButton />
|
||||
<PurgeRemoteMediaButton />
|
||||
<ExportButton disabled={isLoading || total === 0} />
|
||||
</TopToolbar>
|
||||
);
|
||||
@@ -36,19 +38,20 @@ export const UserMediaStatsList = (props: ListProps) => (
|
||||
filters={userMediaStatsFilters}
|
||||
pagination={<UserMediaStatsPagination />}
|
||||
sort={{ field: "media_length", order: "DESC" }}
|
||||
perPage={50}
|
||||
>
|
||||
<Datagrid rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
|
||||
<DatagridConfigurable rowClick={id => "/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>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
const resource: ResourceProps = {
|
||||
name: "user_media_statistics",
|
||||
icon: EqualizerIcon,
|
||||
icon: PermMediaIcon,
|
||||
list: UserMediaStatsList,
|
||||
};
|
||||
|
||||
|
@@ -7,12 +7,18 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
import LockClockIcon from '@mui/icons-material/LockClock';
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Typography } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
Button,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
Create,
|
||||
CreateProps,
|
||||
@@ -34,7 +40,6 @@ import {
|
||||
ResourceProps,
|
||||
SearchInput,
|
||||
SelectInput,
|
||||
BulkDeleteButton,
|
||||
DeleteButton,
|
||||
maxLength,
|
||||
regex,
|
||||
@@ -42,20 +47,40 @@ import {
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
Pagination,
|
||||
SaveButton,
|
||||
CreateButton,
|
||||
ExportButton,
|
||||
BulkDeleteButton,
|
||||
TopToolbar,
|
||||
Toolbar,
|
||||
NumberField,
|
||||
useListContext,
|
||||
useNotify,
|
||||
Identifier,
|
||||
ToolbarClasses,
|
||||
ImageInput,
|
||||
ImageField,
|
||||
FunctionField,
|
||||
useDataProvider,
|
||||
Confirm,
|
||||
useCreate,
|
||||
useRedirect,
|
||||
} from "react-admin";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import AvatarField from "../components/AvatarField";
|
||||
import DeleteUserButton from "../components/DeleteUserButton";
|
||||
import { isASManaged } from "../utils/mxid";
|
||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { DeviceRemoveButton } from "../components/devices";
|
||||
import { DATE_FORMAT } from "../utils/date";
|
||||
import DeviceRemoveButton from "../components/DeviceRemoveButton";
|
||||
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
||||
import { generateRandomPassword } from "../utils/password";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import ExperimentalFeaturesList from "../components/ExperimentalFeatures";
|
||||
import UserRateLimits from "../components/UserRateLimits";
|
||||
import { User, UsernameAvailabilityResult } from "../synapse/dataProvider";
|
||||
import { MakeAdminBtn } from "./rooms";
|
||||
|
||||
const choices_medium = [
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
@@ -80,53 +105,86 @@ const UserListActions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
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 />,
|
||||
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
||||
<BooleanInput label="resources.users.fields.show_suspended" source="suspended" alwaysOn />,
|
||||
];
|
||||
|
||||
const UserBulkActionButtons = () => (
|
||||
<>
|
||||
<ServerNoticeBulkButton />
|
||||
<BulkDeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean; asManagedUserIsSelected: boolean }> = props => {
|
||||
const ownUserIsSelected = props.ownUserIsSelected;
|
||||
const asManagedUserIsSelected = props.asManagedUserIsSelected;
|
||||
const notify = useNotify();
|
||||
const translate = useTranslate();
|
||||
|
||||
const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (ownUserIsSelected) {
|
||||
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
|
||||
ev.stopPropagation();
|
||||
} else if (asManagedUserIsSelected) {
|
||||
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
|
||||
};
|
||||
|
||||
const UserBulkActionButtons = () => {
|
||||
const record = useListContext();
|
||||
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
|
||||
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
||||
const selectedIds = record.selectedIds;
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
|
||||
useEffect(() => {
|
||||
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
|
||||
}, [selectedIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerNoticeBulkButton />
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<DeleteUserButton
|
||||
selectedIds={selectedIds}
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
confirmContent="resources.users.helper.erase_text"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserList = (props: ListProps) => (
|
||||
<List
|
||||
{...props}
|
||||
filters={userFilters}
|
||||
filterDefaultValues={{ guests: true, deactivated: false }}
|
||||
filterDefaultValues={{ guests: true, deactivated: false, locked: false, suspended: false }}
|
||||
sort={{ field: "name", order: "ASC" }}
|
||||
actions={<UserListActions />}
|
||||
pagination={<UserPagination />}
|
||||
perPage={50}
|
||||
>
|
||||
<Datagrid
|
||||
<DatagridConfigurable
|
||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
|
||||
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" />
|
||||
<BooleanField source="locked" />
|
||||
<BooleanField source="erased" sortable={false} />
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" label="resources.users.fields.avatar" />
|
||||
<TextField source="id" sortBy="name" label="resources.users.fields.id" />
|
||||
<TextField source="displayname" label="resources.users.fields.displayname" />
|
||||
<BooleanField source="is_guest" label="resources.users.fields.is_guest" />
|
||||
<BooleanField source="admin" label="resources.users.fields.admin" />
|
||||
<BooleanField source="deactivated" label="resources.users.fields.deactivated" />
|
||||
<BooleanField source="locked" label="resources.users.fields.locked" />
|
||||
<BooleanField source="suspended" label="resources.users.fields.suspended" />
|
||||
<BooleanField source="erased" sortable={false} label="resources.users.fields.erased" />
|
||||
<DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -134,39 +192,103 @@ export const UserList = (props: ListProps) => (
|
||||
// here only local part of user_id
|
||||
// maxLength = 255 - "@" - ":" - storage.getItem("home_server").length
|
||||
// storage.getItem("home_server").length is not valid here
|
||||
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id")];
|
||||
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-\+/]+$/, "synapseadmin.users.invalid_user_id")];
|
||||
|
||||
const validateAddress = [required(), maxLength(255)];
|
||||
|
||||
const UserEditActions = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
let asManagedUserIsSelected = false;
|
||||
if (record && record.id) {
|
||||
ownUserIsSelected = record.id === ownUserId;
|
||||
asManagedUserIsSelected = isASManaged(record.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
{!record?.deactivated && <ServerNoticeButton />}
|
||||
<DeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle={translate("resources.users.helper.erase", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
{record && record.id && (
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<DeleteUserButton
|
||||
selectedIds={[record?.id]}
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
confirmContent="resources.users.helper.erase_text"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
)}
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserCreate = (props: CreateProps) => (
|
||||
<Create
|
||||
export const UserCreate = (props: CreateProps) => {
|
||||
const dataProvider = useDataProvider();
|
||||
const translate = useTranslate();
|
||||
const redirect = useRedirect();
|
||||
const notify = useNotify();
|
||||
const theme = useTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [userIsAvailable, setUserIsAvailable] = useState<boolean | undefined>();
|
||||
const [userAvailabilityEl, setUserAvailabilityEl] = useState<React.ReactElement | false>(<Typography component="span"></Typography>);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [create] = useCreate();
|
||||
|
||||
const checkAvailability = async(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const username = event.target.value;
|
||||
const result: UsernameAvailabilityResult = await dataProvider.checkUsernameAvailability(username);
|
||||
setUserIsAvailable(!!result?.available);
|
||||
if (result?.available) {
|
||||
setUserAvailabilityEl(<Typography component="span" variant="body2" sx={{ color: theme.palette.success.main }}>✔️ {translate("resources.users.helper.username_available")}</Typography>);
|
||||
} else {
|
||||
setUserAvailabilityEl(<Typography component="span" variant="body2" sx={{ color: theme.palette.warning.main }}>⚠️ {result?.error || "unknown error"}</Typography>);
|
||||
}
|
||||
};
|
||||
|
||||
const postSave = (data: Record<string, any>) => {
|
||||
setFormData(data);
|
||||
if (!userIsAvailable) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
create("users", { data: data }, {
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.created", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => { return `users/${resource.id}` });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
updateUser();
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const updateUser = () => {
|
||||
create("users", { data: formData }, {
|
||||
onSuccess: (resource: User) => {
|
||||
notify("ra.notification.updated", { messageArgs: { smart_count: 1 } });
|
||||
redirect(() => { return `users/${resource.id}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return <Create
|
||||
{...props}
|
||||
redirect={(resource: string | undefined, id: Identifier | undefined) => {
|
||||
return `${resource}/${id}`;
|
||||
}}
|
||||
>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||
<SimpleForm
|
||||
onSubmit={postSave}
|
||||
>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} onBlur={checkAvailability} helperText={userAvailabilityEl}/>
|
||||
<TextInput source="displayname" validate={maxLength(256)} />
|
||||
<PasswordInput source="password" autoComplete="new-password" validate={maxLength(512)} />
|
||||
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<ArrayInput source="threepids">
|
||||
@@ -182,36 +304,139 @@ export const UserCreate = (props: CreateProps) => (
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="resources.users.action.overwrite_title"
|
||||
content="resources.users.action.overwrite_content"
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
confirm="resources.users.action.overwrite_confirm"
|
||||
cancel="resources.users.action.overwrite_cancel"
|
||||
/>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTitle = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||
return (
|
||||
<span>
|
||||
{translate("resources.users.name", {
|
||||
smart_count: 1,
|
||||
})}{" "}
|
||||
{record ? `"${record.displayname}"` : ""}
|
||||
{username}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const UserEditToolbar = () => {
|
||||
const record = useRecordContext();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
let asManagedUserIsSelected = false;
|
||||
if (record && record.id) {
|
||||
ownUserIsSelected = record.id === ownUserId;
|
||||
asManagedUserIsSelected = isASManaged(record.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={ToolbarClasses.defaultToolbar}>
|
||||
<Toolbar sx={{ justifyContent: "space-between" }}>
|
||||
<SaveButton />
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<DeleteButton />
|
||||
</UserPreventSelfDelete>
|
||||
</Toolbar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UserBooleanInput = props => {
|
||||
const record = useRecordContext();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
let asManagedUserIsSelected = false;
|
||||
if (record) {
|
||||
ownUserIsSelected = record.id === ownUserId;
|
||||
asManagedUserIsSelected = isASManaged(record.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||
<BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
|
||||
</UserPreventSelfDelete>
|
||||
);
|
||||
};
|
||||
|
||||
const UserPasswordInput = props => {
|
||||
const record = useRecordContext();
|
||||
let asManagedUserIsSelected = false;
|
||||
|
||||
// Get form context to update field value
|
||||
const form = useFormContext();
|
||||
if (record) {
|
||||
asManagedUserIsSelected = isASManaged(record.id);
|
||||
}
|
||||
|
||||
const generatePassword = () => {
|
||||
const password = generateRandomPassword();
|
||||
form.setValue("password", password, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PasswordInput {...props} helperText={asManagedUserIsSelected ? "resources.users.helper.modify_managed_user_error" : (record ? "resources.users.helper.password" : "resources.users.helper.create_password")} disabled={asManagedUserIsSelected} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
label="resources.users.action.generate_password"
|
||||
onClick={generatePassword}
|
||||
sx={{ marginBottom: "10px" }}
|
||||
disabled={asManagedUserIsSelected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserEdit = (props: EditProps) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
||||
<TabbedForm>
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{
|
||||
meta: {
|
||||
include: ["features"] // Tell your dataProvider to include features
|
||||
}
|
||||
}}>
|
||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
|
||||
<TextInput source="id" disabled />
|
||||
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
||||
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
|
||||
<ImageInput
|
||||
source="avatar_file"
|
||||
label="resources.users.fields.avatar"
|
||||
accept={{ "image/*": [".png", ".jpg"] }}
|
||||
>
|
||||
<ImageField source="src" title="Avatar" sx={{ '& img': {
|
||||
width: "120px !important",
|
||||
height: "120px !important",
|
||||
objectFit: "cover !important",
|
||||
borderRadius: '50% !important',
|
||||
}}} />
|
||||
</ImageInput>
|
||||
<TextInput source="id" readOnly />
|
||||
<TextInput source="displayname" />
|
||||
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<BooleanInput source="locked" />
|
||||
<BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||
<UserBooleanInput source="locked" />
|
||||
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||
<UserBooleanInput source="suspended" helperText="resources.users.helper.suspend" />
|
||||
<BooleanInput source="erased" disabled />
|
||||
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
<TextField source="consent_version" />
|
||||
@@ -237,7 +462,7 @@ export const UserEdit = (props: EditProps) => {
|
||||
|
||||
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
|
||||
<ReferenceManyField reference="devices" target="user_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="device_id" sortable={false} />
|
||||
<TextField source="display_name" sortable={false} />
|
||||
<TextField source="last_seen_ip" sortable={false} />
|
||||
@@ -250,7 +475,7 @@ export const UserEdit = (props: EditProps) => {
|
||||
<FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections">
|
||||
<ReferenceField reference="connections" source="id" label={false} link={false}>
|
||||
<ArrayField source="devices[].sessions[0].connections" label="resources.connections.name">
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<Datagrid sx={{ 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%" }} />
|
||||
@@ -272,13 +497,13 @@ export const UserEdit = (props: EditProps) => {
|
||||
perPage={50}
|
||||
sort={{ field: "created_ts", order: "DESC" }}
|
||||
>
|
||||
<Datagrid style={{ width: "100%" }}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={<BulkDeleteButton/>}>
|
||||
<MediaIDField source="media_id" />
|
||||
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
||||
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
||||
<NumberField source="media_length" />
|
||||
<TextField source="media_type" />
|
||||
<TextField source="upload_name" />
|
||||
<TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
|
||||
<FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
|
||||
<TextField source="quarantined_by" />
|
||||
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
||||
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
||||
@@ -289,18 +514,19 @@ export const UserEdit = (props: EditProps) => {
|
||||
|
||||
<FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms">
|
||||
<ReferenceManyField reference="joined_rooms" target="user_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }} rowClick={id => "/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>
|
||||
<Datagrid sx={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
|
||||
<ReferenceField reference="rooms" source="id" label={false} link={false} sortable={false}>
|
||||
<AvatarField source="avatar" sx={{ height: "40px", width: "40px" }} />
|
||||
</ReferenceField>
|
||||
<TextField source="id" label="resources.rooms.fields.room_id" sortable={false}/>
|
||||
<ReferenceField reference="rooms" source="id" label="resources.rooms.fields.name" link={false} sortable={false}>
|
||||
<TextField source="name" />
|
||||
</ReferenceField>
|
||||
<ReferenceField reference="rooms" source="id" label="resources.rooms.fields.joined_members" link={false} sortable={false}>
|
||||
<TextField source="joined_members" sortable={false} />
|
||||
</ReferenceField>
|
||||
<MakeAdminBtn />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
@@ -310,7 +536,7 @@ export const UserEdit = (props: EditProps) => {
|
||||
path="pushers"
|
||||
>
|
||||
<ReferenceManyField reference="pushers" target="user_id" label={false}>
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<Datagrid sx={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="kind" sortable={false} />
|
||||
<TextField source="app_display_name" sortable={false} />
|
||||
<TextField source="app_id" sortable={false} />
|
||||
@@ -322,6 +548,14 @@ export const UserEdit = (props: EditProps) => {
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab label="synapseadmin.users.tabs.experimental" icon={<ScienceIcon />} path="experimental">
|
||||
<ExperimentalFeaturesList />
|
||||
</FormTab>
|
||||
|
||||
<FormTab label="synapseadmin.users.tabs.limits" icon={<LockClockIcon />} path="limits">
|
||||
<UserRateLimits />
|
||||
</FormTab>
|
||||
</TabbedForm>
|
||||
</Edit>
|
||||
);
|
||||
|
@@ -1,3 +0,0 @@
|
||||
const storage = localStorage;
|
||||
|
||||
export default storage;
|
@@ -1,14 +1,14 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
|
||||
import authProvider from "./authProvider";
|
||||
import storage from "../storage";
|
||||
import { HttpError } from "ra-core";
|
||||
|
||||
fetchMock.enableMocks();
|
||||
|
||||
describe("authProvider", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
storage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
@@ -22,25 +22,25 @@ describe("authProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const ret: undefined = await authProvider.login({
|
||||
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"}',
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
expect(storage.getItem("base_url")).toEqual("http://example.com");
|
||||
expect(storage.getItem("user_id")).toEqual("@user:example.com");
|
||||
expect(storage.getItem("access_token")).toEqual("foobar");
|
||||
expect(storage.getItem("device_id")).toEqual("some_device");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,13 +54,13 @@ describe("authProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const ret: undefined = await authProvider.login({
|
||||
const ret = await authProvider.login({
|
||||
base_url: "https://example.com/",
|
||||
loginToken: "login_token",
|
||||
});
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
@@ -68,21 +68,21 @@ describe("authProvider", () => {
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
expect(storage.getItem("base_url")).toEqual("https://example.com");
|
||||
expect(storage.getItem("user_id")).toEqual("@user:example.com");
|
||||
expect(storage.getItem("access_token")).toEqual("foobar");
|
||||
expect(storage.getItem("device_id")).toEqual("some_device");
|
||||
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 storage", async () => {
|
||||
storage.setItem("base_url", "example.com");
|
||||
storage.setItem("access_token", "foo");
|
||||
localStorage.setItem("base_url", "example.com");
|
||||
localStorage.setItem("access_token", "foo");
|
||||
fetchMock.mockResponse(JSON.stringify({}));
|
||||
|
||||
await authProvider.logout(null);
|
||||
|
||||
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
|
||||
expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer foo",
|
||||
@@ -90,7 +90,7 @@ describe("authProvider", () => {
|
||||
method: "POST",
|
||||
user: { authenticated: true, token: "Bearer foo" },
|
||||
});
|
||||
expect(storage.getItem("access_token")).toBeNull();
|
||||
expect(localStorage.getItem("access_token")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,11 +100,11 @@ describe("authProvider", () => {
|
||||
});
|
||||
|
||||
it("should reject if error.status is 401", async () => {
|
||||
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject if error.status is 403", async () => {
|
||||
await expect(authProvider.checkError({ status: 403 })).rejects.toBeUndefined();
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 403, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("authProvider", () => {
|
||||
});
|
||||
|
||||
it("should resolve when logged in", async () => {
|
||||
storage.setItem("access_token", "foobar");
|
||||
localStorage.setItem("access_token", "foobar");
|
||||
|
||||
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -122,7 +122,9 @@ describe("authProvider", () => {
|
||||
|
||||
describe("getPermissions", () => {
|
||||
it("should do nothing", async () => {
|
||||
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
|
||||
if (authProvider.getPermissions) {
|
||||
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { AuthProvider, Options, fetchUtils } from "react-admin";
|
||||
import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { MatrixError, displayError } from "../utils/error";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
import { FetchConfig, ClearConfig } from "../utils/config";
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
// called when the user attempts to log in
|
||||
@@ -9,19 +11,21 @@ const authProvider: AuthProvider = {
|
||||
username,
|
||||
password,
|
||||
loginToken,
|
||||
accessToken,
|
||||
}: {
|
||||
base_url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
loginToken: string;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
console.log("login ");
|
||||
const options: Options = {
|
||||
let options: Options = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
Object.assign(
|
||||
{
|
||||
device_id: storage.getItem("device_id"),
|
||||
device_id: localStorage.getItem("device_id"),
|
||||
initial_device_display_name: "Synapse Admin",
|
||||
},
|
||||
loginToken
|
||||
@@ -31,7 +35,10 @@ const authProvider: AuthProvider = {
|
||||
}
|
||||
: {
|
||||
type: "m.login.password",
|
||||
user: username,
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password: password,
|
||||
}
|
||||
)
|
||||
@@ -41,24 +48,98 @@ const authProvider: AuthProvider = {
|
||||
// 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
|
||||
if (!base_url) {
|
||||
// there is some kind of bug with base_url being present in the form, but not submitted
|
||||
// ref: https://github.com/etkecc/synapse-admin/issues/14
|
||||
localStorage.removeItem("base_url")
|
||||
throw new Error("Homeserver URL is required.");
|
||||
}
|
||||
base_url = base_url.replace(/\/+$/g, "");
|
||||
storage.setItem("base_url", base_url);
|
||||
localStorage.setItem("base_url", base_url);
|
||||
|
||||
const decoded_base_url = window.decodeURIComponent(base_url);
|
||||
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
|
||||
let login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login");
|
||||
|
||||
const { json } = await fetchUtils.fetchJson(login_api_url, options);
|
||||
storage.setItem("home_server", json.home_server);
|
||||
storage.setItem("user_id", json.user_id);
|
||||
storage.setItem("access_token", json.access_token);
|
||||
storage.setItem("device_id", json.device_id);
|
||||
let response;
|
||||
|
||||
try {
|
||||
if (accessToken) {
|
||||
// this a login with an already obtained access token, let's just validate it
|
||||
options = {
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetchUtils.fetchJson(login_api_url, options);
|
||||
const json = response.json;
|
||||
localStorage.setItem("home_server", accessToken ? json.user_id.split(":")[1] : json.home_server);
|
||||
localStorage.setItem("user_id", json.user_id);
|
||||
localStorage.setItem("access_token", accessToken ? accessToken : json.access_token);
|
||||
localStorage.setItem("device_id", json.device_id);
|
||||
localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials");
|
||||
await FetchConfig();
|
||||
|
||||
return Promise.resolve({redirectTo: "/"});
|
||||
} catch(err) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
return Promise.reject(
|
||||
new HttpError(
|
||||
errMsg,
|
||||
errorStatus,
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
getIdentity: async () => {
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
const user_id = localStorage.getItem("user_id");
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
|
||||
if (typeof access_token !== "string" || typeof user_id !== "string" || typeof base_url !== "string") {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const whoami_api_url = base_url + `/_matrix/client/v3/profile/${user_id}`;
|
||||
|
||||
try {
|
||||
let avatar_url = "";
|
||||
const response = await fetchUtils.fetchJson(whoami_api_url, options);
|
||||
if (response.json.avatar_url) {
|
||||
const mediaresp = await fetchAuthenticatedMedia(response.json.avatar_url, "thumbnail");
|
||||
const blob = await mediaresp.blob();
|
||||
avatar_url = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: user_id,
|
||||
fullName: response.json.displayname,
|
||||
avatar: avatar_url,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error getting identity", err);
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
// called when the user clicks on the logout button
|
||||
logout: async () => {
|
||||
console.log("logout");
|
||||
|
||||
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/r0/logout";
|
||||
const access_token = storage.getItem("access_token");
|
||||
const logout_api_url = localStorage.getItem("base_url") + "/_matrix/client/v3/logout";
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
|
||||
const options: Options = {
|
||||
method: "POST",
|
||||
@@ -69,22 +150,28 @@ const authProvider: AuthProvider = {
|
||||
};
|
||||
|
||||
if (typeof access_token === "string") {
|
||||
await fetchUtils.fetchJson(logout_api_url, options);
|
||||
storage.removeItem("access_token");
|
||||
try {
|
||||
await fetchUtils.fetchJson(logout_api_url, options);
|
||||
} catch (err) {
|
||||
console.log("Error logging out", err);
|
||||
} finally {
|
||||
ClearConfig();
|
||||
}
|
||||
}
|
||||
},
|
||||
// called when the API returns an error
|
||||
checkError: ({ status }: { status: number }) => {
|
||||
console.log("checkError " + status);
|
||||
checkError: (err: HttpError) => {
|
||||
const errorBody = err.body as MatrixError;
|
||||
const status = err.status;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return Promise.reject();
|
||||
return Promise.reject({message: displayError(errorBody.errcode, status, errorBody.error)});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
// called when the user navigates to a new location, to check for authentication
|
||||
checkAuth: () => {
|
||||
const access_token = storage.getItem("access_token");
|
||||
console.log("checkAuth " + access_token);
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
return typeof access_token === "string" ? Promise.resolve() : Promise.reject();
|
||||
},
|
||||
// called when the user navigates to a new location, to check for permissions / roles
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
|
||||
import dataProvider from "./dataProvider";
|
||||
import storage from "../storage";
|
||||
|
||||
fetchMock.enableMocks();
|
||||
|
||||
@@ -10,15 +9,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("dataProvider", () => {
|
||||
storage.setItem("base_url", "http://localhost");
|
||||
storage.setItem("access_token", "access_token");
|
||||
localStorage.setItem("base_url", "http://localhost");
|
||||
localStorage.setItem("access_token", "access_token");
|
||||
|
||||
it("fetches all users", async () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
users: [
|
||||
{
|
||||
name: "user_id1",
|
||||
name: "@user_id1:provider",
|
||||
password_hash: "password_hash1",
|
||||
is_guest: 0,
|
||||
admin: 0,
|
||||
@@ -27,7 +26,7 @@ describe("dataProvider", () => {
|
||||
displayname: "User One",
|
||||
},
|
||||
{
|
||||
name: "user_id2",
|
||||
name: "@user_id2:provider",
|
||||
password_hash: "password_hash2",
|
||||
is_guest: 0,
|
||||
admin: 1,
|
||||
@@ -47,7 +46,7 @@ describe("dataProvider", () => {
|
||||
filter: { author_id: 12 },
|
||||
});
|
||||
|
||||
expect(users.data[0].id).toEqual("user_id1");
|
||||
expect(users.data[0].id).toEqual("@user_id1:provider");
|
||||
expect(users.total).toEqual(200);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -55,7 +54,7 @@ describe("dataProvider", () => {
|
||||
it("fetches one user", async () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
name: "user_id1",
|
||||
name: "@user_id1:provider",
|
||||
password: "user_password",
|
||||
displayname: "User",
|
||||
threepids: [
|
||||
@@ -74,9 +73,9 @@ describe("dataProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const user = await dataProvider.getOne("users", { id: "user_id1" });
|
||||
const user = await dataProvider.getOne("users", { id: "@user_id1:provider" });
|
||||
|
||||
expect(user.data.id).toEqual("user_id1");
|
||||
expect(user.data.id).toEqual("@user_id1:provider");
|
||||
expect(user.data.displayname).toEqual("User");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@@ -1,34 +1,57 @@
|
||||
import { stringify } from "query-string";
|
||||
import {
|
||||
DataProvider,
|
||||
DeleteParams,
|
||||
DeleteManyParams,
|
||||
HttpError,
|
||||
Identifier,
|
||||
Options,
|
||||
PaginationPayload,
|
||||
RaRecord,
|
||||
SortPayload,
|
||||
UpdateParams,
|
||||
fetchUtils,
|
||||
withLifecycleCallbacks,
|
||||
} from "react-admin";
|
||||
|
||||
import { DataProvider, DeleteParams, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { returnMXID } from "../utils/mxid";
|
||||
import { MatrixError, displayError } from "../utils/error";
|
||||
|
||||
// Adds the access token to all requests
|
||||
const jsonClient = (url: string, options: Options = {}) => {
|
||||
const token = storage.getItem("access_token");
|
||||
const jsonClient = async (url: string, options: Options = {}) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
console.log("httpClient " + url);
|
||||
if (token != null) {
|
||||
if (token !== null) {
|
||||
options.user = {
|
||||
authenticated: true,
|
||||
token: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
return fetchUtils.fetchJson(url, options);
|
||||
try {
|
||||
const response = await fetchUtils.fetchJson(url, options);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode
|
||||
? displayError(errorBody.errcode, errorStatus, errorBody.error)
|
||||
: displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
|
||||
}
|
||||
};
|
||||
|
||||
const mxcUrlToHttp = (mxcUrl: string) => {
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||
const 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 filterUndefined = (obj: Record<string, any>) => {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
|
||||
};
|
||||
|
||||
interface Room {
|
||||
interface Action {
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
body?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
room_id: string;
|
||||
name?: string;
|
||||
canonical_alias?: string;
|
||||
@@ -96,7 +119,8 @@ interface ExternalId {
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
export interface User {
|
||||
id?: string;
|
||||
name: string;
|
||||
displayname?: string;
|
||||
threepids: Threepid[];
|
||||
@@ -114,6 +138,7 @@ interface User {
|
||||
external_ids: ExternalId[];
|
||||
user_type?: string;
|
||||
locked: boolean;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
@@ -214,17 +239,91 @@ export interface DeleteMediaResult {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UploadMediaParams {
|
||||
file: File;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
content_uri: string;
|
||||
}
|
||||
|
||||
export interface ExperimentalFeaturesModel {
|
||||
features: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RateLimitsModel {
|
||||
messages_per_second?: number;
|
||||
burst_count?: number;
|
||||
}
|
||||
|
||||
export interface UsernameAvailabilityResult {
|
||||
available?: boolean;
|
||||
error?: string;
|
||||
errcode?: string;
|
||||
}
|
||||
|
||||
export interface ServerStatusComponent {
|
||||
ok: boolean;
|
||||
category: string;
|
||||
reason: string;
|
||||
url: string;
|
||||
help: string;
|
||||
label: {
|
||||
url: string;
|
||||
icon: string;
|
||||
text: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerStatusResponse {
|
||||
success: boolean;
|
||||
ok: boolean;
|
||||
host: string;
|
||||
results: ServerStatusComponent[];
|
||||
}
|
||||
|
||||
export interface ServerProcessResponse {
|
||||
locked_at?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface ServerNotification {
|
||||
event_id: string;
|
||||
output: string;
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
export interface ServerNotificationsResponse {
|
||||
success: boolean;
|
||||
notifications: ServerNotification[];
|
||||
}
|
||||
|
||||
export interface SynapseDataProvider extends DataProvider {
|
||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
|
||||
getRateLimits: (id: Identifier) => Promise<RateLimitsModel>;
|
||||
setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>;
|
||||
checkUsernameAvailability: (username: string) => Promise<UsernameAvailabilityResult>;
|
||||
makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>;
|
||||
getServerRunningProcess: (etkeAdminUrl: string) => Promise<ServerProcessResponse>;
|
||||
getServerStatus: (etkeAdminUrl: string) => Promise<ServerStatusResponse>;
|
||||
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
|
||||
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
users: {
|
||||
path: "/_synapse/admin/v2/users",
|
||||
map: (u: User) => ({
|
||||
map: async (u: User) => ({
|
||||
...u,
|
||||
id: u.name,
|
||||
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
|
||||
id: returnMXID(u.name),
|
||||
avatar_src: u.avatar_url ? u.avatar_url : undefined,
|
||||
is_guest: !!u.is_guest,
|
||||
admin: !!u.admin,
|
||||
deactivated: !!u.deactivated,
|
||||
@@ -234,12 +333,12 @@ const resourceMap = {
|
||||
data: "users",
|
||||
total: json => json.total,
|
||||
create: (data: RaRecord) => ({
|
||||
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`,
|
||||
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(returnMXID(data.id))}`,
|
||||
body: data,
|
||||
method: "PUT",
|
||||
}),
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
|
||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`,
|
||||
body: { erase: true },
|
||||
method: "POST",
|
||||
}),
|
||||
@@ -259,7 +358,7 @@ const resourceMap = {
|
||||
total: json => json.total_rooms,
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
|
||||
body: { block: false },
|
||||
body: { block: params.meta?.block ?? false },
|
||||
}),
|
||||
},
|
||||
reports: {
|
||||
@@ -300,6 +399,20 @@ const resourceMap = {
|
||||
data: "members",
|
||||
total: json => json.total,
|
||||
},
|
||||
room_media: {
|
||||
map: (mediaId: string) => ({
|
||||
id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""),
|
||||
media_id: mediaId.replace("mxc://" + localStorage.getItem("home_server") + "/", ""),
|
||||
}),
|
||||
reference: (id: Identifier) => ({
|
||||
endpoint: `/_synapse/admin/v1/room/${id}/media`,
|
||||
}),
|
||||
total: json => json.total,
|
||||
data: "local",
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`,
|
||||
}),
|
||||
},
|
||||
room_state: {
|
||||
map: (rs: RoomState) => ({
|
||||
...rs,
|
||||
@@ -338,12 +451,12 @@ const resourceMap = {
|
||||
id: um.media_id,
|
||||
}),
|
||||
reference: (id: Identifier) => ({
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`,
|
||||
}),
|
||||
data: "media",
|
||||
total: json => json.total,
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/media/${storage.getItem("home_server")}/${params.id}`,
|
||||
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem("home_server")}/${params.id}`,
|
||||
}),
|
||||
},
|
||||
protect_media: {
|
||||
@@ -360,11 +473,11 @@ const resourceMap = {
|
||||
quarantine_media: {
|
||||
map: (qm: UserMedia) => ({ id: qm.media_id }),
|
||||
create: (params: UserMedia) => ({
|
||||
endpoint: `/_synapse/admin/v1/media/quarantine/${storage.getItem("home_server")}/${params.media_id}`,
|
||||
endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem("home_server")}/${params.media_id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/media/unquarantine/${storage.getItem("home_server")}/${params.id}`,
|
||||
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem("home_server")}/${params.id}`,
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
@@ -373,7 +486,7 @@ const resourceMap = {
|
||||
create: (data: RaServerNotice) => ({
|
||||
endpoint: "/_synapse/admin/v1/send_server_notice",
|
||||
body: {
|
||||
user_id: data.id,
|
||||
user_id: returnMXID(data.id),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: data.body,
|
||||
@@ -386,7 +499,7 @@ const resourceMap = {
|
||||
path: "/_synapse/admin/v1/statistics/users/media",
|
||||
map: (usms: UserMediaStatistic) => ({
|
||||
...usms,
|
||||
id: usms.user_id,
|
||||
id: returnMXID(usms.user_id),
|
||||
}),
|
||||
data: "users",
|
||||
total: json => json.total,
|
||||
@@ -412,7 +525,7 @@ const resourceMap = {
|
||||
id: rd.room_id,
|
||||
public: !!rd.public,
|
||||
guest_access: !!rd.guest_access,
|
||||
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
|
||||
avatar_src: rd.avatar_url ? rd.avatar_url : undefined,
|
||||
}),
|
||||
data: "chunk",
|
||||
total: json => json.total_room_count_estimate,
|
||||
@@ -488,12 +601,12 @@ function getSearchOrder(order: "ASC" | "DESC") {
|
||||
}
|
||||
}
|
||||
|
||||
const dataProvider: SynapseDataProvider = {
|
||||
const baseDataProvider: SynapseDataProvider = {
|
||||
getList: async (resource, params) => {
|
||||
console.log("getList " + resource);
|
||||
const { user_id, name, guests, deactivated, search_term, destination, valid } = params.filter;
|
||||
const { page, perPage } = params.pagination;
|
||||
const { field, order } = params.sort;
|
||||
const { user_id, name, guests, deactivated, locked, suspended, search_term, destination, valid } = params.filter;
|
||||
const { page, perPage } = params.pagination as PaginationPayload;
|
||||
const { field, order } = params.sort as SortPayload;
|
||||
const from = (page - 1) * perPage;
|
||||
const query = {
|
||||
from: from,
|
||||
@@ -504,28 +617,32 @@ const dataProvider: SynapseDataProvider = {
|
||||
destination: destination,
|
||||
guests: guests,
|
||||
deactivated: deactivated,
|
||||
locked: locked,
|
||||
suspended: suspended,
|
||||
valid: valid,
|
||||
order_by: field,
|
||||
dir: getSearchOrder(order),
|
||||
};
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const url = `${endpoint_url}?${stringify(query)}`;
|
||||
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||
|
||||
const { json } = await jsonClient(url);
|
||||
const formattedData = await json[res.data].map(res.map);
|
||||
|
||||
return {
|
||||
data: json[res.data].map(res.map),
|
||||
data: formattedData,
|
||||
total: res.total(json, from, perPage),
|
||||
};
|
||||
},
|
||||
|
||||
getOne: async (resource, params) => {
|
||||
console.log("getOne " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -537,13 +654,28 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
getMany: async (resource, params) => {
|
||||
console.log("getMany " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homerserver not set");
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
if (!base_url || !(resource in resourceMap)) throw Error("base_url not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const responses = await Promise.all(params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)));
|
||||
const endpoint_url = base_url + res.path;
|
||||
const responses = await Promise.all(params.ids.map(id => {
|
||||
// edge case: when user is external / federated, homeserver will return error, as querying external users via
|
||||
// /_synapse/admin/v2/users is not allowed.
|
||||
// That leads to an issue when a user is referenced (e.g., in room state datagrid) - the user cell is just empty.
|
||||
// To avoid that, we fake the response with one specific field (name) which is used in the datagrid.
|
||||
if (homeserver && resource === "users") {
|
||||
if (!(<string>id).endsWith(homeserver)) {
|
||||
const json = {
|
||||
name: id,
|
||||
};
|
||||
return Promise.resolve({ json });
|
||||
}
|
||||
}
|
||||
return jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`);
|
||||
}));
|
||||
return {
|
||||
data: responses.map(({ json }) => res.map(json)),
|
||||
total: responses.length,
|
||||
@@ -562,13 +694,13 @@ const dataProvider: SynapseDataProvider = {
|
||||
dir: getSearchOrder(order),
|
||||
};
|
||||
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const ref = res.reference(params.id);
|
||||
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
|
||||
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return {
|
||||
@@ -579,7 +711,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
update: async (resource, params) => {
|
||||
console.log("update " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -594,7 +726,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
updateMany: async (resource, params) => {
|
||||
console.log("updateMany " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -611,7 +743,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
create: async (resource, params) => {
|
||||
console.log("create " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -628,7 +760,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
createMany: async (resource: string, params: { ids: Identifier[]; data: RaRecord }) => {
|
||||
console.log("createMany " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -650,7 +782,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
|
||||
delete: async (resource, params) => {
|
||||
console.log("delete " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -674,8 +806,8 @@ const dataProvider: SynapseDataProvider = {
|
||||
},
|
||||
|
||||
deleteMany: async (resource, params) => {
|
||||
console.log("deleteMany " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
console.log("deleteMany " + resource, "params", params);
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
@@ -691,6 +823,7 @@ const dataProvider: SynapseDataProvider = {
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
data: responses.map(({ json }) => json),
|
||||
};
|
||||
@@ -721,14 +854,279 @@ const dataProvider: SynapseDataProvider = {
|
||||
* @returns
|
||||
*/
|
||||
deleteMedia: async ({ before_ts, size_gt = 0, keep_profiles = true }) => {
|
||||
const homeserver = storage.getItem("home_server"); // TODO only required for synapse < 1.78.0
|
||||
const homeserver = localStorage.getItem("home_server"); // TODO only required for synapse < 1.78.0
|
||||
const endpoint = `/_synapse/admin/v1/media/${homeserver}/delete?before_ts=${before_ts}&size_gt=${size_gt}&keep_profiles=${keep_profiles}`;
|
||||
|
||||
const base_url = storage.getItem("base_url");
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = base_url + endpoint;
|
||||
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
||||
return json as DeleteMediaResult;
|
||||
},
|
||||
|
||||
/**
|
||||
* Purge remote media by date
|
||||
*
|
||||
* @link https://element-hq.github.io/synapse/latest/admin_api/media_admin_api.html#purge-remote-media-api
|
||||
*
|
||||
* @param before_ts Unix timestamp in milliseconds. Files that were last used before this timestamp will be deleted. It is the timestamp of last access, not the timestamp when the file was created.
|
||||
* @returns
|
||||
*/
|
||||
purgeRemoteMedia: async ({ before_ts }) => {
|
||||
const endpoint = `/_synapse/admin/v1/purge_media_cache?before_ts=${before_ts}`;
|
||||
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = base_url + endpoint;
|
||||
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
||||
return json as DeleteMediaResult;
|
||||
},
|
||||
|
||||
uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
|
||||
|
||||
const { json } = await jsonClient(`${uploadMediaURL}?filename=${filename}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": content_type,
|
||||
}) as Headers,
|
||||
});
|
||||
return json as UploadMediaResult;
|
||||
},
|
||||
getFeatures: async (id: Identifier) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json.features as ExperimentalFeaturesModel;
|
||||
},
|
||||
updateFeatures: async (id: Identifier, features: ExperimentalFeaturesModel) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||
await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) });
|
||||
},
|
||||
getRateLimits: async (id: Identifier) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`;
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json as RateLimitsModel;
|
||||
},
|
||||
setRateLimits: async (id: Identifier, rateLimits: RateLimitsModel) => {
|
||||
const filtered = Object.entries(rateLimits).
|
||||
filter(([key, value]) => value !== null && value !== undefined).
|
||||
reduce((obj, [key, value]) => {
|
||||
obj[key] = value;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`;
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||
return
|
||||
}
|
||||
|
||||
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(filtered) });
|
||||
},
|
||||
checkUsernameAvailability: async (username: string) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/username_available?username=${encodeURIComponent(username)}`;
|
||||
try {
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json as UsernameAvailabilityResult;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return { available: false, error: error.body.error, errcode: error.body.errcode } as UsernameAvailabilityResult;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
makeRoomAdmin: async (room_id: string, user_id: string) => {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/rooms/${encodeURIComponent(room_id)}/make_room_admin`;
|
||||
try {
|
||||
const { json } = await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ user_id }) });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return { success: false, error: error.body.error, errcode: error.body.errcode };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getServerRunningProcess: async (runningProcessUrl: string): Promise<ServerProcessResponse> => {
|
||||
const locked_at = "";
|
||||
const command = "";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${runningProcessUrl}/lock`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server running process: ${response.status} ${response.statusText}`);
|
||||
return { locked_at, command };
|
||||
}
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as { locked_at: string; command: string };
|
||||
}
|
||||
if (status === 204) {
|
||||
return { locked_at, command };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting server running process", error);
|
||||
}
|
||||
|
||||
return { locked_at, command };
|
||||
},
|
||||
getServerStatus: async (serverStatusUrl: string): Promise<ServerStatusResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${serverStatusUrl}/status`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server status: ${response.status} ${response.statusText}`);
|
||||
return { success: false, ok: false, host: "", results: [] };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
const result = { success: true, ...json } as ServerStatusResponse;
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting server status", error);
|
||||
}
|
||||
|
||||
return { success: false, ok: false, host: "", results: [] };
|
||||
},
|
||||
getServerNotifications: async (serverNotificationsUrl: string): Promise<ServerNotificationsResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${serverNotificationsUrl}/notifications`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error getting server notifications: ${response.status} ${response.statusText}`);
|
||||
return { success: false, notifications: [] };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 204) {
|
||||
return { success: true, notifications: [] };
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
const result = { success: true, notifications: json } as ServerNotificationsResponse;
|
||||
return result;
|
||||
}
|
||||
|
||||
return { success: true, notifications: [] };
|
||||
} catch (error) {
|
||||
console.error("Error getting server notifications", error);
|
||||
}
|
||||
|
||||
return { success: false, notifications: [] };
|
||||
},
|
||||
deleteServerNotifications: async (serverNotificationsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverNotificationsUrl}/notifications`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
},
|
||||
method: "DELETE"
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting server notifications: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
if (status === 204) {
|
||||
const result = { success: true }
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting server notifications", error);
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
{
|
||||
resource: "users",
|
||||
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
|
||||
const avatarFile = params.data.avatar_file?.rawFile;
|
||||
const avatarErase = params.data.avatar_erase;
|
||||
const rates = params.data.rates;
|
||||
|
||||
if (rates) {
|
||||
await dataProvider.setRateLimits(params.id, rates);
|
||||
delete params.data.rates;
|
||||
}
|
||||
|
||||
if (avatarErase) {
|
||||
params.data.avatar_url = "";
|
||||
return params;
|
||||
}
|
||||
|
||||
if (avatarFile instanceof File) {
|
||||
const reponse = await dataProvider.uploadMedia({
|
||||
file: avatarFile,
|
||||
filename: params.data.avatar_file.title,
|
||||
content_type: params.data.avatar_file.rawFile.type,
|
||||
});
|
||||
params.data.avatar_url = reponse.content_uri;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
beforeDelete: async (params: DeleteParams<any>, dataProvider: DataProvider) => {
|
||||
if (params.meta?.deleteMedia) {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(params.id))}/media`;
|
||||
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
if (params.meta?.redactEvents) {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(params.id))}/redact`;
|
||||
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
beforeDeleteMany: async (params: DeleteManyParams<any>, dataProvider: DataProvider) => {
|
||||
await Promise.all(
|
||||
params.ids.map(async id => {
|
||||
if (params.meta?.deleteMedia) {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`;
|
||||
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
if (params.meta?.redactEvents) {
|
||||
const base_url = localStorage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(id))}/redact`;
|
||||
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
|
||||
}
|
||||
})
|
||||
);
|
||||
return params;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default dataProvider;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { isValidBaseUrl, splitMxid } from "./synapse";
|
||||
import { isValidBaseUrl, splitMxid } from "./matrix";
|
||||
|
||||
describe("splitMxid", () => {
|
||||
it("splits valid MXIDs", () =>
|
@@ -1,6 +1,6 @@
|
||||
import { fetchUtils } from "react-admin";
|
||||
import { Identifier, fetchUtils } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { isMXID } from "../utils/mxid";
|
||||
|
||||
export const splitMxid = mxid => {
|
||||
const re = /^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
|
||||
@@ -49,36 +49,7 @@ export const getSupportedFeatures = async baseUrl => {
|
||||
* @returns array of supported login flows
|
||||
*/
|
||||
export const getSupportedLoginFlows = async baseUrl => {
|
||||
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
|
||||
const loginFlowsUrl = `${baseUrl}/_matrix/client/v3/login`;
|
||||
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
|
||||
return response.json.flows;
|
||||
};
|
||||
|
||||
export const getMediaUrl = media_id => {
|
||||
const baseUrl = storage.getItem("base_url");
|
||||
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a random MXID for current homeserver
|
||||
* @returns full MXID as string
|
||||
*/
|
||||
export function generateRandomMxId(): string {
|
||||
const homeserver = storage.getItem("home_server");
|
||||
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
|
||||
.map(x => characters[x % characters.length])
|
||||
.join("");
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random user password
|
||||
* @returns a new random password as string
|
||||
*/
|
||||
export function generateRandomPassword(length = 20): string {
|
||||
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$";
|
||||
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
|
||||
.map(x => characters[x % characters.length])
|
||||
.join("");
|
||||
}
|
95
src/utils/config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface Config {
|
||||
restrictBaseUrl: string | string[];
|
||||
asManagedUsers: RegExp[];
|
||||
menu: MenuItem[];
|
||||
etkeccAdmin?: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const WellKnownKey = "cc.etke.synapse-admin";
|
||||
|
||||
// current configuration
|
||||
let config: Config = {
|
||||
restrictBaseUrl: "",
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
etkeccAdmin: ""
|
||||
};
|
||||
|
||||
export const FetchConfig = async () => {
|
||||
try {
|
||||
const resp = await fetch("config.json");
|
||||
const configJSON = await resp.json();
|
||||
console.log("Loaded config.json", configJSON);
|
||||
LoadConfig(configJSON);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
let protocol = "https";
|
||||
const baseURL = localStorage.getItem("base_url");
|
||||
if (baseURL && baseURL.startsWith("http://")) {
|
||||
protocol = "http";
|
||||
}
|
||||
|
||||
// if home_server is set, try to load https://home_server/.well-known/matrix/client
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
if (homeserver) {
|
||||
try {
|
||||
const resp = await fetch(`${protocol}://${homeserver}/.well-known/matrix/client`);
|
||||
const configWK = await resp.json();
|
||||
if (!configWK[WellKnownKey]) {
|
||||
console.log(`Loaded ${protocol}://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK);
|
||||
} else {
|
||||
console.log(`Loaded ${protocol}://${homeserver}.well-known/matrix/client`, configWK);
|
||||
LoadConfig(configWK[WellKnownKey]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load config from context
|
||||
// we deliberately processing each key separately to avoid overwriting the whole config, loosing some keys, and messing
|
||||
// with typescript types
|
||||
export const LoadConfig = (context: any) => {
|
||||
if (context?.restrictBaseUrl) {
|
||||
config.restrictBaseUrl = context.restrictBaseUrl as string | string[];
|
||||
}
|
||||
|
||||
if (context?.asManagedUsers) {
|
||||
config.asManagedUsers = context.asManagedUsers.map((regex: string) => new RegExp(regex));
|
||||
}
|
||||
|
||||
let menu: MenuItem[] = [];
|
||||
if (context?.menu) {
|
||||
menu = context.menu as MenuItem[];
|
||||
}
|
||||
if (menu.length > 0) {
|
||||
config.menu = menu;
|
||||
}
|
||||
|
||||
if (context?.etkeccAdmin) {
|
||||
config.etkeccAdmin = context.etkeccAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
// get config
|
||||
export const GetConfig = (): Config => {
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
// clear config
|
||||
export const ClearConfig = () => {
|
||||
// config.json
|
||||
config = {} as Config;
|
||||
// session
|
||||
localStorage.clear();
|
||||
}
|
6
src/utils/error.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type MatrixError = {
|
||||
errcode: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`;
|
40
src/utils/fetchMedia.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
|
||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||
const ret = re.exec(mxcUrl);
|
||||
if (ret == null) {
|
||||
throw new Error("Invalid mxcUrl");
|
||||
}
|
||||
const serverName = ret[1];
|
||||
const mediaId = ret[2];
|
||||
return { serverName, mediaId };
|
||||
};
|
||||
|
||||
export type MediaType = "thumbnail" | "original";
|
||||
|
||||
export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise<Response> => {
|
||||
const homeserver = localStorage.getItem("base_url");
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
|
||||
const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
|
||||
if (!serverName || !mediaId) {
|
||||
throw new Error("Invalid mxcUrl");
|
||||
}
|
||||
|
||||
let url = "";
|
||||
if (type === "thumbnail") {
|
||||
// ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
|
||||
url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
|
||||
} else if (type === "original") {
|
||||
url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
|
||||
} else {
|
||||
throw new Error("Invalid authenticated media type");
|
||||
}
|
||||
|
||||
const response = await fetch(`${url}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
16
src/utils/icons.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import AnnouncementIcon from '@mui/icons-material/Announcement';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import HelpCenterIcon from '@mui/icons-material/HelpCenter';
|
||||
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
|
||||
export const Icons = {
|
||||
Announcement: AnnouncementIcon,
|
||||
Engineering: EngineeringIcon,
|
||||
HelpCenter: HelpCenterIcon,
|
||||
SupportAgent: SupportAgentIcon,
|
||||
Default: OpenInNewIcon,
|
||||
// Add more icons as needed
|
||||
};
|
||||
|
||||
export const DefaultIcon = Icons.Default;
|
61
src/utils/mxid.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Identifier } from "ra-core";
|
||||
import { GetConfig } from "../utils/config";
|
||||
|
||||
const mxidPattern = /^@[^@:]+:[^@:]+$/;
|
||||
|
||||
/*
|
||||
* Check if id is a valid Matrix ID (user)
|
||||
* @param id The ID to check
|
||||
* @returns Whether the ID is a valid Matrix ID
|
||||
*/
|
||||
export const isMXID = (id: string | Identifier): boolean => mxidPattern.test(id as string);
|
||||
|
||||
/**
|
||||
* Check if a user is managed by an application service
|
||||
* @param id The user ID to check
|
||||
* @returns Whether the user is managed by an application service
|
||||
*/
|
||||
export const isASManaged = (id: string | Identifier): boolean => {
|
||||
const managedUsers = GetConfig().asManagedUsers;
|
||||
if (!managedUsers) {
|
||||
return false;
|
||||
}
|
||||
return managedUsers.some(regex => regex.test(id as string));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a random MXID for current homeserver
|
||||
* @returns full MXID as string
|
||||
*/
|
||||
export function generateRandomMXID(): string {
|
||||
const homeserver = localStorage.getItem("home_server");
|
||||
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
|
||||
.map(x => characters[x % characters.length])
|
||||
.join("");
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full MXID from an arbitrary input
|
||||
* @param input the input string
|
||||
* @returns full MXID as string
|
||||
*/
|
||||
export function returnMXID(input: string | Identifier): string {
|
||||
const inputStr = input as string;
|
||||
const homeserver = localStorage.getItem("home_server") || "";
|
||||
|
||||
// when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address
|
||||
if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) {
|
||||
return inputStr; // Already a valid MXID
|
||||
}
|
||||
|
||||
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
|
||||
if (isMXID(input)) {
|
||||
return inputStr; // Already a valid MXID
|
||||
}
|
||||
|
||||
// If input is not a valid MXID, assume it's a localpart and construct the MXID
|
||||
const localpart = typeof input === 'string' && inputStr.startsWith('@') ? inputStr.slice(1) : inputStr;
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
10
src/utils/password.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generate a random user password
|
||||
* @returns a new random password as string
|
||||
*/
|
||||
export function generateRandomPassword(length = 64): string {
|
||||
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~`!@#$%^&*()_-+={[}]|:;'.?/<>,";
|
||||
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
|
||||
.map(x => characters[x % characters.length])
|
||||
.join("");
|
||||
}
|
191
testdata/synapse/homeserver.yaml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
account_threepid_delegates:
|
||||
msisdn: ''
|
||||
alias_creation_rules:
|
||||
- action: allow
|
||||
alias: '*'
|
||||
room_id: '*'
|
||||
user_id: '*'
|
||||
allow_guest_access: false
|
||||
allow_public_rooms_over_federation: true
|
||||
allow_public_rooms_without_auth: true
|
||||
app_service_config_files: []
|
||||
autocreate_auto_join_rooms: true
|
||||
background_updates: null
|
||||
caches:
|
||||
global_factor: 0.5
|
||||
per_cache_factors: null
|
||||
cas_config: null
|
||||
database:
|
||||
args:
|
||||
cp_max: 10
|
||||
cp_min: 5
|
||||
database: synapse
|
||||
host: postgres
|
||||
password: synapse
|
||||
port: 5432
|
||||
user: synapse
|
||||
name: psycopg2
|
||||
txn_limit: 0
|
||||
default_room_version: '10'
|
||||
disable_msisdn_registration: true
|
||||
email:
|
||||
enable_media_repo: true
|
||||
enable_metrics: false
|
||||
enable_registration: false
|
||||
enable_registration_captcha: false
|
||||
enable_registration_without_verification: false
|
||||
enable_room_list_search: true
|
||||
encryption_enabled_by_default_for_room_type: 'off'
|
||||
event_cache_size: 100K
|
||||
federation_rr_transactions_per_room_per_second: 50
|
||||
form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi
|
||||
include_profile_data_on_invite: true
|
||||
instance_map: {}
|
||||
limit_profile_requests_to_users_who_share_rooms: false
|
||||
limit_remote_rooms: null
|
||||
listeners:
|
||||
- bind_addresses:
|
||||
- '::'
|
||||
port: 8008
|
||||
resources:
|
||||
- compress: false
|
||||
names:
|
||||
- client
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
log_config: /config/synapse.log.config
|
||||
macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE
|
||||
manhole_settings: null
|
||||
max_spider_size: 10M
|
||||
max_upload_size: 1024M
|
||||
media_retention:
|
||||
local_media_lifetime: 30d
|
||||
remote_media_lifetime: 7d
|
||||
media_storage_providers: []
|
||||
media_store_path: /media-store
|
||||
metrics_flags: null
|
||||
modules: []
|
||||
oembed: null
|
||||
oidc_providers: null
|
||||
old_signing_keys: null
|
||||
opentracing: null
|
||||
password_config:
|
||||
enabled: true
|
||||
localdb_enabled: true
|
||||
pepper: zfvnYqxe3GTkdJ9BlfZiAqy2zMsjOg02uBTEiWLp2hjQGqlDw33pTSTplE6HoWlF
|
||||
policy: null
|
||||
pid_file: /homeserver.pid
|
||||
presence:
|
||||
enabled: true
|
||||
public_baseurl: http://synapse:8008/
|
||||
push:
|
||||
include_content: true
|
||||
rc_admin_redaction:
|
||||
burst_count: 50
|
||||
per_second: 1
|
||||
rc_federation:
|
||||
concurrent: 3
|
||||
reject_limit: 50
|
||||
sleep_delay: 500
|
||||
sleep_limit: 10
|
||||
window_size: 1000
|
||||
rc_invites:
|
||||
per_issuer:
|
||||
burst_count: 10
|
||||
per_second: 0.3
|
||||
per_room:
|
||||
burst_count: 10
|
||||
per_second: 0.3
|
||||
per_user:
|
||||
burst_count: 5
|
||||
per_second: 0.003
|
||||
rc_joins:
|
||||
local:
|
||||
burst_count: 10
|
||||
per_second: 0.1
|
||||
remote:
|
||||
burst_count: 10
|
||||
per_second: 0.01
|
||||
rc_login:
|
||||
account:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
address:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
failed_attempts:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
rc_message:
|
||||
burst_count: 10
|
||||
per_second: 0.2
|
||||
rc_registration:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
recaptcha_private_key: ''
|
||||
recaptcha_public_key: ''
|
||||
redaction_retention_period: 5m
|
||||
redis:
|
||||
enabled: false
|
||||
host: null
|
||||
password: null
|
||||
port: 6379
|
||||
registration_requires_token: false
|
||||
registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9
|
||||
report_stats: false
|
||||
require_auth_for_profile_requests: false
|
||||
retention:
|
||||
enabled: true
|
||||
purge_jobs:
|
||||
- interval: 12h
|
||||
room_list_publication_rules:
|
||||
- action: allow
|
||||
alias: '*'
|
||||
room_id: '*'
|
||||
user_id: '*'
|
||||
room_prejoin_state: null
|
||||
saml2_config:
|
||||
sp_config: null
|
||||
user_mapping_provider:
|
||||
config: null
|
||||
server_name: synapse:8008
|
||||
signing_key_path: /config/synapse.signing.key
|
||||
spam_checker: []
|
||||
sso: null
|
||||
stats: null
|
||||
stream_writers: {}
|
||||
templates: null
|
||||
tls_certificate_path: null
|
||||
tls_private_key_path: null
|
||||
trusted_key_servers:
|
||||
- server_name: matrix.org
|
||||
turn_allow_guests: false
|
||||
ui_auth: null
|
||||
url_preview_accept_language:
|
||||
- en-US
|
||||
- en
|
||||
url_preview_enabled: true
|
||||
url_preview_ip_range_blacklist:
|
||||
- 127.0.0.0/8
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 100.64.0.0/10
|
||||
- 192.0.0.0/24
|
||||
- 169.254.0.0/16
|
||||
- 192.88.99.0/24
|
||||
- 198.18.0.0/15
|
||||
- 192.0.2.0/24
|
||||
- 198.51.100.0/24
|
||||
- 203.0.113.0/24
|
||||
- 224.0.0.0/4
|
||||
- ::1/128
|
||||
- fe80::/10
|
||||
- fc00::/7
|
||||
- 2001:db8::/32
|
||||
- ff00::/8
|
||||
- fec0::/10
|
||||
user_directory: null
|
||||
user_ips_max_age: 5m
|
||||
|
28
testdata/synapse/synapse.log.config
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
version: 1
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
filters:
|
||||
context:
|
||||
(): synapse.util.logcontext.LoggingContextFilter
|
||||
request: ""
|
||||
handlers:
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
filters: [context]
|
||||
loggers:
|
||||
synapse:
|
||||
level: INFO
|
||||
shared_secret_authenticator:
|
||||
level: INFO
|
||||
rest_auth_provider:
|
||||
level: INFO
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: INFO
|
||||
root:
|
||||
level: INFO
|
||||
handlers: [console]
|
||||
|
1
testdata/synapse/synapse.signing.key
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo
|
@@ -24,7 +24,7 @@
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
|
@@ -5,13 +5,41 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
vitePluginVersionMark({
|
||||
command: "git describe --tags",
|
||||
ifMeta: true,
|
||||
ifLog: true,
|
||||
name: "Synapse Admin",
|
||||
command: 'git describe --tags || git rev-parse --short HEAD || echo "${SYNAPSE_ADMIN_VERSION:-unknown}"',
|
||||
ifMeta: false,
|
||||
ifLog: false,
|
||||
ifGlobal: true,
|
||||
outputFile: (version) => ({
|
||||
path: "manifest.json",
|
||||
content: JSON.stringify({
|
||||
name: "Synapse Admin",
|
||||
version: version,
|
||||
description: "Synapse Admin is an admin console for synapse Matrix homeserver with additional features.",
|
||||
categories: ["productivity", "utilities"],
|
||||
orientation: "landscape",
|
||||
icons: [{
|
||||
src: "favicon.ico",
|
||||
sizes: "32x32",
|
||||
type: "image/x-icon"
|
||||
},{
|
||||
src: "images/logo.webp",
|
||||
sizes: "512x512",
|
||||
type: "image/webp",
|
||||
purpose: "any maskable"
|
||||
}],
|
||||
start_url: ".",
|
||||
display: "standalone",
|
||||
theme_color: "#000000",
|
||||
background_color: "#ffffff"
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|