Compare commits

..

37 Commits

Author SHA1 Message Date
Manuel Stahl
444f648191 Merge tag '0.8.3' into amp.chat
Change-Id: I011f6b7d22f636af768bac3c88527eeb300198ce
2021-08-25 16:00:00 +01:00
Manuel Stahl
af51cca461 Merge tag '0.8.2' into amp.chat
Change-Id: I791cad80267ddaa61966d3c2da9767e1a6a3c589
2021-07-05 16:00:00 +01:00
Manuel Stahl
364234f4f4 Merge tag '0.8.1' into amp.chat
Change-Id: Ice2d3474ef9284bcfea81a4e0043d798edcfaa03
2021-05-25 16:00:00 +01:00
Manuel Stahl
1fe0b2f330 Fix linting errors
Change-Id: Ie804aa67de43f66ecb5d7890826285b09656c2e5
2021-11-23 07:47:31 +00:00
Timo Paulssen
1aaa137afe Import users from CSV
Change-Id: Id05363ecc39aee4fdc4ac6afbcb039558b2a17ed
2021-08-25 09:39:49 +02:00
Manuel Stahl
7b5c0e2845 Merge tag '0.8.0' into amp.chat
Change-Id: I2e362a911083149c82a8c11b6c4594bb4c760a33
2021-05-04 15:07:42 +02:00
Manuel Stahl
da8cb12756 Merge tag '0.7.2' into amp.chat
Change-Id: Ideb662d56977082af5757fed21573ff25ca52e27
2021-05-04 14:32:28 +02:00
Manuel Stahl
56a359b704 Merge tag '0.7.1' into amp.chat
Change-Id: Ib19b2cd22bae62b22057a3782ac978f83097fdd5
2021-05-04 14:32:05 +02:00
Michael Albert
5906dcc129 Merge tag '0.7.0' into amp.chat
Change-Id: I44a26f1fa0946a2b2beeb014d6905cdd2d15aaf6
2021-02-17 23:30:02 +01:00
Elshad Shirinov
270d48607a Allow server admin to create rooms for other users and change user power levels
Change-Id: Ie96e9e0102454835536b6f42d247f9e714e28480
2020-11-12 18:46:33 +01:00
Michael Albert
931fafc21d Allow to set user_type 'limited'
Change-Id: Ic3942a2150b9dfe57c106eb595b49b774fe8a30c
2020-10-20 18:56:04 +02:00
Michael Albert
c604b47adc Allow to set a usertype
Change-Id: Ibfaa383b95dc5acc3b4dcd61f3f506f7c81f7dea
2020-10-20 13:57:23 +02:00
Manuel Stahl
fb8cff3e3e Merge tag '0.5.0' into amp.chat
Change-Id: I410e194bc7b153c69e00f40a4486a46924cd510a
2020-09-03 09:08:01 +02:00
Michael Albert
725e24d944 Add credentials to PDF
Fix Umlauts in PDF
Reorder elements of PDF

Change-Id: I49335584ef282e4b960275013ea7d16053b9f773
2020-08-24 07:57:40 +00:00
Manuel Stahl
dd00a76603 Merge tag '0.4.2' into amp.chat
Change-Id: Id12309f0a4d3ff9983325e69131d5eebe5bd0bde
2020-07-30 12:56:20 +02:00
Manuel Stahl
2915fd3e5b Merge tag '0.4.1' into amp.chat
Change-Id: I44c9f00e5aa7abe413f8a819e1143bebc4f08ce2
2020-07-28 15:09:48 +02:00
Michael Albert
a4662c2557 Translate room info
Change-Id: I7f3121da3c910592ecfcb4bca9dee34f2757f567
2020-06-10 07:18:58 +00:00
Michael Albert
f6ca169fbc Fix data provider
Change-Id: Id1c929f593833ed35327e70d1d0dc8182a4b7306
2020-05-25 21:20:49 +02:00
Michael Albert
07862591fd Possibility to encrypt new rooms
Change-Id: Ie415a0f8ecec646510ac8f2f0adca58064e30da5
2020-05-25 13:25:46 +02:00
Manuel Stahl
ab649fbf70 Merge branch 'master' into amp.chat
Change-Id: I6141964157bcb7218e2e6368a3ca8d20eb4855e9
2020-05-05 13:44:06 +02:00
Timo Paulssen
880223e5de Offer invitations in room creation
Turns the "Create Room" form into a tabbed form with
tabs that mimic the room display. In the "Members" tab
an AutocompleteArrayInput allows selecting multiple
users by their displayname.

The displayname is also what is displayed ìn the
invitations list.

Creating the room immediately sends out the invitations
as well.

Change-Id: I3915144114ffe4c629848363c9cb7917634d04d8
2020-04-28 19:36:15 +02:00
Manuel Stahl
76fdc80e3e Merge branch 'master' into amp.chat
Change-Id: I08a7a34e041993c29bb12fff52d07534374cda4e
2020-04-28 16:35:40 +02:00
Manuel Stahl
375649756f Add page to show room details
Change-Id: Iec4f402c4322d775cc14c567069a3295ad383b44
2020-04-28 16:30:47 +02:00
Manuel Stahl
662735a91f Adapt for changes in v2/users API
Change-Id: I927b81882fa20e5b3de3d9fc216e2136f7036bba
2020-04-28 16:30:47 +02:00
Manuel Stahl
0823976edd Cleanup room creation
Change-Id: Ieb5189513d21606f8d0bea5692112350a68f2e14
2020-04-23 16:31:26 +02:00
Michael Albert
d3cd2e9e33 Fix localStorage entry of homeserver url
Change-Id: I206e3b4428df1f51d4281ad4db26bd64bdffb85d
2020-04-21 17:42:43 +02:00
Timo Paulssen
24abcd4e4a Normalize alias a little, display initial sigil
turns all whitespace into underscores, shows leading
sigil if the alias is non-empty, so the user doesn't get
confused about whether they have to input a # or not.

Change-Id: Ic81e69cc3f0074d63a67b976c9bda32f8de025de
2020-04-20 19:31:33 +02:00
Timo Paulssen
c1c32e3268 Offer "alias" field in room create form
Tries its best to not allow aliases that are too
long (full alias including leading #, : in the
middle, and homeserver domain name must not exceed
255 bytes.

Change-Id: I1e784a94cb599eca7e30736d666b20e37aad5444
2020-04-20 19:31:33 +02:00
Timo Paulssen
ca15435625 Offer room creation form
A choice of public or private is offered, which maps to matrix'
visibility parameter. A name can also be provided.

Change-Id: I34d99acbc4624a9ed54ca6f6609573d5fc1049da
2020-04-20 19:31:33 +02:00
Michael Albert
e9c3901b68 Merge branch 'master' into amp.chat
Change-Id: Iac4e56401aab3f7f39b623b617990ec1952f8cd0
2020-04-20 16:57:23 +02:00
Michael Albert
7aec6f9369 Allow searching for users
Change-Id: Icf4a3b05b24c66971f55b22e7540a1dc904a3a92
2020-04-20 11:22:06 +00:00
Michael Albert
d2a3f07a59 Fix QR code creation
Change-Id: Ib6bbd1be6d4dca1f617043c3c2338924b2321ea3
2020-04-20 12:15:52 +02:00
Manuel Stahl
bf7867f106 Merge branch 'master' into amp.chat
Change-Id: I45b7a6db27456aaa2eca66b406cdaa49e492e61e
2020-02-11 18:56:53 +01:00
Michael Albert
f0e32abc4f Fix QR code creation
Change-Id: If05856a6fdafa43a93c6b57963820710db188d42
2020-02-11 17:35:19 +00:00
Michael Albert
61b1580735 Fix redirect after create/edit user
Change-Id: Icdb797bf6b1a47cbeff901b1952672584b2e8e8f
2020-02-11 17:34:32 +00:00
Manuel Stahl
0f7e4c1909 Create PDF with QR code on user create/edit
Change-Id: Ib89b68e956d96002ddbf6ac5ddcaea73b5b3e3fb
2020-02-10 13:10:08 +01:00
Michael Albert
c9bce409d2 Prefill user_id and password on user creation
Change-Id: I3f604f38c1842f155f3b39da20ba45992ba522be
2020-02-10 13:10:08 +01:00
62 changed files with 10903 additions and 10474 deletions

View File

@ -1,20 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ignore:
# Major updates for react-admin have breaking changes
- dependency-name: "react-admin"
update-types: ["version-update:semver-major"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: "18"
node-version: 14
- name: Install dependencies
run: yarn --immutable
run: yarn --frozen-lockfile
- name: Run tests
run: yarn test

View File

@ -1,63 +1,36 @@
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
name: Create docker image(s) and push to docker hub
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
- '[0-9]+\.[0-9]+\.[0-9]+'
jobs:
docker:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-tags: true
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
awesometechnologies/synapse-admin
ghcr.io/${{ github.repository }}
- name: Build and Push Tag
uses: docker/build-push-action@v5
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
push: true
tags: awesometechnologies/synapse-admin:latest
- name: Update repo description
uses: peter-evans/dockerhub-description@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: awesometechnologies/synapse-admin

View File

@ -1,28 +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-tags: true
- uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install and Build 🔧
run: |
yarn install --immutable
yarn build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4.5.0
with:
branch: gh-pages
folder: build

View File

@ -8,25 +8,20 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
fetch-tags: true
- uses: actions/setup-node@v4
with:
node-version: "18"
- run: yarn install --immutable
node-version: "14"
- run: yarn install
- run: yarn build
- run: |
version=`git describe --dirty --tags || echo unknown`
mkdir -p dist
cp -r build synapse-admin-$version
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
- uses: softprops/action-gh-release@3198ee18f814cdf787321b4a32a26ddbf37acc52
- uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
files: dist/*.tar.gz
env:

View File

@ -1,51 +0,0 @@
name: Test docker image creation
on:
push:
# Sequence of patterns matched against refs/heads
# prettier-ignore
branches:
# Push events on branch fix_docker_cd
- fix_docker_cd
# Sequence of patterns matched against refs/tags
tags:
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Calculate docker image tag
id: set-tag
run: |
case "${GITHUB_REF}" in
refs/heads/master|refs/heads/main)
tag=latest
;;
refs/tags/*)
tag=${GITHUB_REF#refs/tags/}
;;
*)
tag=${GITHUB_SHA}
;;
esac
echo "::set-output name=tag::$tag"
- name: Build and Push Tag
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
platforms: linux/amd64,linux/arm64

View File

@ -6,6 +6,6 @@
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid"
"jsxBracketSameLine": false,
"arrowParens": "avoid",
}

5
.travis.yml Normal file
View File

@ -0,0 +1,5 @@
language: node_js
node_js:
- lts/*
cache: yarn

View File

@ -1,13 +1,11 @@
# Builder
FROM node:lts as builder
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
ARG REACT_APP_SERVER
FROM node:current as builder
WORKDIR /src
COPY . /src
RUN yarn --network-timeout=300000 install --immutable
RUN REACT_APP_SERVER=$REACT_APP_SERVER yarn build
RUN yarn --network-timeout=100000 install
RUN yarn build
# App

View File

@ -1,53 +1,33 @@
[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
[![Build Status](https://travis-ci.org/Awesome-Technologies/synapse-admin.svg?branch=master)](https://travis-ci.org/Awesome-Technologies/synapse-admin)
[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
# Synapse admin ui
This project is built using [react-admin](https://marmelab.com/react-admin/).
## Usage
### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.52.0 for all functions to work as expected!
It needs at least Synapse v1.38.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).
See also [Synapse version API](https://matrix-org.github.io/synapse/develop/admin_api/version_api.html).
After entering the URL on the login page of synapse-admin the server version appears below the input field.
### Prerequisites
You need access to the following endpoints:
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
See also [Synapse administration endpoints](https://matrix-org.github.io/synapse/develop/reverse_proxy.html#synapse-administration-endpoints)
### Use without install
You can use the current version of Synapse Admin without own installation direct
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
**Note:**
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
**Remember: You have no need to expose these endpoints to the internet but to your network.**
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
### Step-By-Step install
## Step-By-Step install:
You have three options:
1. [Download the tarball and serve with any webserver](#steps-for-1)
2. [Download the source code from github and run using nodejs](#steps-for-2)
3. [Run the Docker container](#steps-for-3)
1. Download the tarball and serve with any webserver
2. Download the source code from github and run using nodejs
3. Run the Docker container
#### Steps for 1)
Steps for 1):
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
- configure a vhost for synapse admin on your webserver
@ -56,7 +36,7 @@ You have three options:
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
- open the url of the vhost in your browser
#### Steps for 2)
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`
@ -69,7 +49,7 @@ Either you define it at startup (e.g. `REACT_APP_SERVER=https://yourmatrixserver
or by editing it in the [.env](.env) file. See also the
[documentation](https://create-react-app.dev/docs/adding-custom-environment-variables/).
#### Steps for 3)
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`
@ -86,9 +66,6 @@ or by editing it in the [.env](.env) file. See also the
context: https://github.com/Awesome-Technologies/synapse-admin.git
# args:
# - NODE_OPTIONS="--max_old_space_size=1024"
# # see #266, PUBLIC_URL must be without surrounding quotation marks
# - PUBLIC_URL=/synapse-admin
# - REACT_APP_SERVER="https://matrix.example.com"
ports:
- "8080:80"
restart: unless-stopped

View File

@ -12,15 +12,10 @@ services:
# replace the context definition with this:
# context: https://github.com/Awesome-Technologies/synapse-admin.git
# args:
# if you're building on an architecture other than amd64, make sure
# to define a maximum ram for node. otherwise the build will fail.
# args:
# - NODE_OPTIONS="--max_old_space_size=1024"
# default is .
# - PUBLIC_URL=/synapse-admin
# You can use a fixed homeserver, so that the user can no longer
# define it himself
# - REACT_APP_SERVER="https://matrix.example.com"
ports:
- "8080:80"
restart: unless-stopped

View File

@ -1,6 +1,6 @@
{
"name": "synapse-admin",
"version": "0.9.2",
"version": "AMP/2021.08",
"description": "Admin GUI for the Matrix.org server Synapse",
"author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0",
@ -10,31 +10,29 @@
"url": "https://github.com/Awesome-Technologies/synapse-admin"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.2",
"@testing-library/user-event": "^14.5.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^5.1.3",
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.8",
"eslint": "^7.25.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.1.2",
"jest-fetch-mock": "^3.0.3",
"prettier": "^3.2.5"
"prettier": "^2.2.0",
"ra-test": "^3.15.0"
},
"dependencies": {
"@mui/icons-material": "^5.15.15",
"@mui/material": "^5.15.15",
"@mui/styles": "^5.15.15",
"papaparse": "^5.4.1",
"@progress/kendo-drawing": "^1.6.0",
"@progress/kendo-react-pdf": "^3.10.1",
"babel-preset-jest": "^24.9.0",
"papaparse": "^5.2.0",
"prop-types": "^15.7.2",
"qrcode.react": "^1.0.0",
"ra-language-chinese": "^2.0.10",
"ra-language-french": "^4.16.15",
"ra-language-german": "^3.13.4",
"ra-language-italian": "^3.13.1",
"ra-language-farsi": "^4.2.0",
"ra-language-russian": "^4.14.2",
"react": "^18.0.0",
"react-admin": "^4.16.15",
"react-dom": "^18.0.0",
"react-scripts": "^5.0.1"
"react": "^17.0.0",
"react-admin": "^3.15.0",
"react-dom": "^17.0.2",
"react-scripts": "^4.0.0"
},
"scripts": {
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",

View File

@ -1,3 +1,3 @@
id,displayname,password,is_guest,admin,deactivated
testuser22,Jane Doe,secretpassword,false,true,false
@testuser22:example.org,Jane Doe,secretpassword,false,true,false
,John Doe,,false,false,false

1 id displayname password is_guest admin deactivated
2 testuser22 @testuser22:example.org Jane Doe secretpassword false true false
3 John Doe false false false

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

View File

@ -9,6 +9,32 @@
name="description"
content="Synapse-Admin"
/>
<style>
@font-face {
font-family: "DejaVu Sans";
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-weight: bold;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Bold.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-style: italic;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Oblique.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-weight: bold;
font-style: italic;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Oblique.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans Mono";
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Mono.ttf") format("truetype");
}
</style>
<!--
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/

View File

@ -1,3 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

90
src/App.js Normal file
View File

@ -0,0 +1,90 @@
import React from "react";
import { Admin, Resource, resolveBrowserLocale } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList, RoomCreate, RoomShow, RoomEdit } from "./components/rooms";
import { ReportList, ReportShow } from "./components/EventReports";
import ImportFeature from "./components/ImportFeature";
import LoginPage from "./components/LoginPage";
import UserIcon from "@material-ui/icons/Group";
import EqualizerIcon from "@material-ui/icons/Equalizer";
import { UserMediaStatsList } from "./components/statistics";
import RoomIcon from "@material-ui/icons/ViewList";
import ReportIcon from "@material-ui/icons/Warning";
import FolderSharedIcon from "@material-ui/icons/FolderShared";
import { RoomDirectoryList } from "./components/RoomDirectory";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import chineseMessages from "./i18n/zh";
import ShowUserPdf from "./components/ShowUserPdf";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
customRoutes={[
<Route key="csvImport" path="/importcsv" component={ImportFeature} />,
<Route key="showpdf" path="/showpdf" component={ShowUserPdf} />,
]}
>
<Resource
name="users"
list={UserList}
create={UserCreate}
edit={UserEdit}
icon={UserIcon}
/>
<Resource
name="rooms"
list={RoomList}
create={RoomCreate}
show={RoomShow}
edit={RoomEdit}
icon={RoomIcon}
/>
<Resource
name="user_media_statistics"
list={UserMediaStatsList}
icon={EqualizerIcon}
/>
<Resource
name="reports"
list={ReportList}
show={ReportShow}
icon={ReportIcon}
/>
<Resource
name="room_directory"
list={RoomDirectoryList}
icon={FolderSharedIcon}
/>
<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" />
</Admin>
);
export default App;

View File

@ -1,72 +0,0 @@
import React from "react";
import {
Admin,
CustomRoutes,
Resource,
resolveBrowserLocale,
} from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import users from "./components/users";
import rooms from "./components/rooms";
import userMediaStats from "./components/statistics";
import reports from "./components/EventReports";
import roomDirectory from "./components/RoomDirectory";
import destinations from "./components/destinations";
import LoginPage from "./components/LoginPage";
import { ImportFeature } from "./components/ImportFeature";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
import italianMessages from "./i18n/it";
import russianMessages from "./i18n/ru";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
zh: chineseMessages,
ru: russianMessages
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
);
export default App;

9
src/App.test.js Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
describe("App", () => {
it("renders", () => {
render(<App />);
});
});

View File

@ -1,10 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";
describe("App", () => {
it("renders", async () => {
render(<App />);
await screen.findAllByText("Welcome to Synapse-admin");
});
});

View File

@ -1,22 +0,0 @@
import React from "react";
import get from "lodash/get";
import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin";
const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const { alt, classes, sizes, sx, variant } = rest;
return (
<Avatar
alt={alt}
classes={classes}
sizes={sizes}
src={src}
sx={sx}
variant={variant}
/>
);
};
export default AvatarField;

View File

@ -1,18 +0,0 @@
import React from "react";
import { RecordContextProvider } from "react-admin";
import { render, screen } from "@testing-library/react";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
it("shows image", () => {
const value = {
avatar: "foo",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
});
});

View File

@ -2,7 +2,6 @@ import React from "react";
import {
Datagrid,
DateField,
DeleteButton,
List,
NumberField,
Pagination,
@ -11,31 +10,19 @@ import {
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import PageviewIcon from "@mui/icons-material/Pageview";
import ReportIcon from "@mui/icons-material/Warning";
import ViewListIcon from "@mui/icons-material/ViewList";
import PageviewIcon from "@material-ui/icons/Pageview";
import ViewListIcon from "@material-ui/icons/ViewList";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const ReportPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const ReportPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = props => {
const translate = useTranslate();
return (
<Show {...props} actions={<ReportShowActions />}>
<Show {...props}>
<TabbedShowLayout>
<Tab
label={translate("synapseadmin.reports.tabs.basic", {
@ -46,7 +33,14 @@ export const ReportShow = props => {
<DateField
source="received_ts"
showTime
options={date_format}
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<ReferenceField source="user_id" reference="users">
@ -74,16 +68,23 @@ export const ReportShow = props => {
icon={<PageviewIcon />}
path="detail"
>
{" "}
<DateField
source="event_json.origin_server_ts"
showTime
options={date_format}
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<ReferenceField source="sender" reference="users">
<TextField source="id" />
</ReferenceField>
<TextField source="sender" label="Sender (raw user ID)" />
<TextField source="event_id" />
<TextField source="event_json.origin" />
<TextField source="event_json.type" />
@ -94,7 +95,7 @@ export const ReportShow = props => {
<TextField source="event_json.content.algorithm" />
<TextField
source="event_json.content.device_id"
label="resources.devices.fields.device_id"
label="resources.users.fields.device_id"
/>
</Tab>
</TabbedShowLayout>
@ -102,47 +103,33 @@ export const ReportShow = props => {
);
};
const ReportShowActions = () => {
const record = useRecordContext();
export const ReportList = ({ ...props }) => {
return (
<TopToolbar>
<DeleteButton
record={record}
mutationMode="pessimistic"
confirmTitle="resources.reports.action.erase.title"
confirmContent="resources.reports.action.erase.content"
/>
</TopToolbar>
<List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick="show">
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" />
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
};
export const ReportList = props => (
<List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
>
<Datagrid rowClick="show" bulkActionButtons={false}>
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" />
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
const resource = {
name: "reports",
icon: ReportIcon,
list: ReportList,
show: ReportShow,
};
export default resource;

View File

@ -1,22 +1,42 @@
import React, { useState } from "react";
import { useDataProvider, useNotify, Title } from "react-admin";
import {
Button as ReactAdminButton,
useDataProvider,
useNotify,
Title,
} from "react-admin";
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
import GetAppIcon from "@material-ui/icons/GetApp";
import {
Button,
Card,
CardActions,
CardContent,
CardHeader,
Checkbox,
Container,
FormControlLabel,
Checkbox,
NativeSelect,
} from "@mui/material";
} from "@material-ui/core";
import { useTranslate } from "ra-core";
import Container from "@material-ui/core/Container/Container";
import { generateRandomUser } from "./users";
import ShowUserPdf from "./ShowUserPdf";
const LOGGING = true;
export const ImportButton = ({ label, variant = "text" }) => {
return (
<ReactAdminButton
color="primary"
component="span"
variant={variant}
label={label}
>
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</ReactAdminButton>
);
};
const expectedFields = ["id", "displayname"].sort();
const optionalFields = [
"user_type",
@ -32,7 +52,7 @@ function TranslatableOption({ value, text }) {
return <option value={value}>{translate(text)}</option>;
}
const FilePicker = () => {
const FilePicker = props => {
const [values, setValues] = useState(null);
const [error, setError] = useState(null);
const [stats, setStats] = useState(null);
@ -40,6 +60,8 @@ const FilePicker = () => {
const [progress, setProgress] = useState(null);
const [pdfRecords, setPdfRecords] = useState(null);
const [importResults, setImportResults] = useState(null);
const [skippedRecords, setSkippedRecords] = useState(null);
@ -47,17 +69,23 @@ const FilePicker = () => {
const [passwordMode, setPasswordMode] = useState(true);
const [useridMode, setUseridMode] = useState("ignore");
const [showingPdf, setShowingPdf] = useState(false);
const translate = useTranslate();
const notify = useNotify();
const dataProvider = useDataProvider();
const onFileChange = async e => {
if (progress !== null) return;
if (progress !== null) {
return;
}
if (LOGGING) console.log("onFileChange was called");
setValues(null);
setError(null);
setStats(null);
setPdfRecords(null);
setImportResults(null);
const file = e.target.files ? e.target.files[0] : null;
/* Let's refuse some unreasonably big files instead of freezing
@ -107,6 +135,11 @@ const FilePicker = () => {
});
if (eF.length !== 0) {
if (LOGGING) {
console.log(meta.fields);
console.log(eF);
console.log(oF);
}
setError(
translate("import_users.error.required_field", { field: eF[0] })
);
@ -191,7 +224,7 @@ const FilePicker = () => {
return true;
};
const runImport = async _e => {
const runImport = async e => {
if (progress !== null) {
notify("import_users.errors.already_in_progress");
return;
@ -207,6 +240,9 @@ const FilePicker = () => {
setProgress,
setError
);
setPdfRecords(results.recordsForPdf);
setImportResults(results);
// offer CSV download of skipped or errored records
// (so that the user doesn't have to filter out successful
@ -232,6 +268,8 @@ const FilePicker = () => {
let skippedRecords = [];
let erroredRecords = [];
let succeededRecords = [];
let recordsForPdf = [];
let changeStats = {
toAdmin: 0,
toGuest: 0,
@ -307,7 +345,7 @@ const FilePicker = () => {
let retries = 0;
const submitRecord = recordData => {
return dataProvider.getOne("users", { id: recordData.id }).then(
async _alreadyExists => {
async alreadyExists => {
if (LOGGING) console.log("already existed");
if (useridMode === "update" || conflictMode === "skip") {
@ -332,7 +370,7 @@ const FilePicker = () => {
}
}
},
async _okToSubmit => {
async okToSubmit => {
if (LOGGING)
console.log(
"OK to create record " +
@ -346,6 +384,14 @@ const FilePicker = () => {
await dataProvider.create("users", { data: recordData });
}
succeededRecords.push(recordData);
if (recordData.password !== undefined) {
recordsForPdf.push({
id: recordData.id,
password: recordData.password,
displayname: recordData.displayname,
});
}
}
);
};
@ -370,6 +416,7 @@ const FilePicker = () => {
erroredRecords,
succeededRecords,
totalRecordCount: entriesCount,
recordsForPdf,
changeStats,
wasDryRun: dryRun,
};
@ -599,6 +646,10 @@ const FilePicker = () => {
<br />,
]
: ""}
{translate(
"import_users.cards.results.for_print",
importResults.recordsForPdf.length
)}
<br />
{importResults.wasDryRun && [
translate("import_users.cards.results.simulated_only"),
@ -636,20 +687,43 @@ const FilePicker = () => {
</CardActions>
);
let allCards = [];
if (uploadCard) allCards.push(uploadCard);
if (errorCards) allCards.push(errorCards);
if (conflictCards) allCards.push(conflictCards);
if (statsCards) allCards.push(...statsCards);
if (startImportCard) allCards.push(startImportCard);
if (resultsCard) allCards.push(resultsCard);
let pdfDisplay =
pdfRecords && showingPdf && pdfRecords.length ? (
<ShowUserPdf records={pdfRecords} />
) : null;
let cardContainer = <Card>{allCards}</Card>;
let pdfActions = pdfRecords ? (
<CardActions>
<Button
size="large"
onClick={e => {
setShowingPdf(true);
}}
>
{translate("import_users.goToPdf")}
</Button>
</CardActions>
) : null;
return [
<Title defaultTitle={translate("import_users.title")} />,
cardContainer,
];
if (pdfRecords && showingPdf) {
return <Card>{pdfDisplay}</Card>;
} else {
let allCards = [];
if (uploadCard) allCards.push(uploadCard);
if (errorCards) allCards.push(errorCards);
if (conflictCards) allCards.push(conflictCards);
if (statsCards) allCards.push(...statsCards);
if (startImportCard) allCards.push(startImportCard);
if (resultsCard) allCards.push(resultsCard);
if (pdfActions) allCards.push(pdfActions);
let cardContainer = <Card>{allCards}</Card>;
return [
<Title defaultTitle={translate("import_users.title")} />,
cardContainer,
];
}
};
export const ImportFeature = FilePicker;

293
src/components/LoginPage.js Normal file
View File

@ -0,0 +1,293 @@
import React, { useState, useEffect } from "react";
import {
fetchUtils,
FormDataConsumer,
Notification,
useLogin,
useNotify,
useLocale,
useSetLocale,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { Form, useForm } from "react-final-form";
import {
Avatar,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
TextField,
} from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import LockIcon from "@material-ui/icons/Lock";
const useStyles = makeStyles(theme => ({
main: {
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1em)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
},
card: {
minWidth: "30em",
marginTop: "6em",
marginBottom: "6em",
},
avatar: {
margin: "1em",
display: "flex",
justifyContent: "center",
},
icon: {
backgroundColor: theme.palette.secondary.main,
},
hint: {
marginTop: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[500],
},
form: {
padding: "0 1em 1em 1em",
},
input: {
marginTop: "1em",
},
actions: {
padding: "0 1em 1em 1em",
},
serverVersion: {
color: "#9e9e9e",
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
}));
const LoginPage = ({ theme }) => {
const classes = useStyles({ theme });
const login = useLogin();
const notify = useNotify();
const [loading, setLoading] = useState(false);
var locale = useLocale();
const setLocale = useSetLocale();
const translate = useTranslate();
const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
);
const validate = values => {
const errors = {};
if (!values.username) {
errors.username = translate("ra.validation.required");
}
if (!values.password) {
errors.password = translate("ra.validation.required");
}
if (!values.base_url) {
errors.base_url = translate("ra.validation.required");
} else {
if (!values.base_url.match(/^(http|https):\/\//)) {
errors.base_url = translate("synapseadmin.auth.protocol_error");
} else if (
!values.base_url.match(
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/
)
) {
errors.base_url = translate("synapseadmin.auth.url_error");
}
}
return errors;
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
"warning"
);
});
};
const extractHomeServer = username => {
const usernameRegex = /@[a-zA-Z0-9._=\-/]+:([a-zA-Z0-9\-.]+\.[a-zA-Z]+)/;
if (!username) return null;
const res = username.match(usernameRegex);
if (res) return res[1];
return null;
};
const UserData = ({ formData }) => {
const form = useForm();
const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordially
const home_server = extractHomeServer(formData.username);
const wellKnownUrl = `https://${home_server}/.well-known/matrix/client`;
if (home_server) {
// fetch .well-known entry to get base_url
fetchUtils
.fetchJson(wellKnownUrl, { method: "GET" })
.then(({ json }) => {
form.change("base_url", json["m.homeserver"].base_url);
})
.catch(_ => {
// if there is no .well-known entry, try the home server name
form.change("base_url", `https://${home_server}`);
});
}
};
useEffect(
_ => {
if (
!formData.base_url ||
!formData.base_url.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+$/)
)
return;
const versionUrl = `${formData.base_url}/_synapse/admin/v1/server_version`;
fetchUtils
.fetchJson(versionUrl, { method: "GET" })
.then(({ json }) => {
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${
json["server_version"]
}`
);
})
.catch(_ => {
setServerVersion("");
});
},
[formData.base_url]
);
return (
<div>
<div className={classes.input}>
<TextInput
autoFocus
name="username"
component={renderInput}
label={translate("ra.auth.username")}
disabled={loading}
onBlur={handleUsernameChange}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<PasswordInput
name="password"
component={renderInput}
label={translate("ra.auth.password")}
type="password"
disabled={loading}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<TextInput
name="base_url"
component={renderInput}
label={translate("synapseadmin.auth.base_url")}
disabled={cfg_base_url || loading}
resettable
fullWidth
/>
</div>
<div className={classes.serverVersion}>{serverVersion}</div>
</div>
);
};
return (
<Form
initialValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<Avatar className={classes.icon}>
<LockIcon />
</Avatar>
</div>
<div className={classes.hint}>
{translate("synapseadmin.auth.welcome")}
</div>
<div className={classes.form}>
<div className={classes.input}>
<Select
value={locale}
onChange={e => {
setLocale(e.target.value);
}}
fullWidth
disabled={loading}
>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="zh">简体中文</MenuItem>
</Select>
</div>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate("ra.auth.sign_in")}
</Button>
</CardActions>
</Card>
<Notification />
</div>
</form>
)}
/>
);
};
export default LoginPage;

View File

@ -1,329 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Form,
FormDataConsumer,
Notification,
required,
useLogin,
useNotify,
useLocaleState,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { useFormContext } from "react-hook-form";
import {
Avatar,
Box,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
Typography,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock";
import {
getServerVersion,
getSupportedFeatures,
getSupportedLoginFlows,
getWellKnownUrl,
isValidBaseUrl,
splitMxid,
} from "../synapse/synapse";
const FormBox = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1rem)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
[`& .card`]: {
width: "30rem",
marginTop: "6rem",
marginBottom: "6rem",
},
[`& .avatar`]: {
margin: "1rem",
display: "flex",
justifyContent: "center",
},
[`& .icon`]: {
backgroundColor: theme.palette.grey[500],
},
[`& .hint`]: {
marginTop: "1em",
marginBottom: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[600],
},
[`& .form`]: {
padding: "0 1rem 1rem 1rem",
},
[`& .select`]: {
marginBottom: "2rem",
},
[`& .actions`]: {
padding: "0 1rem 1rem 1rem",
},
[`& .serverVersion`]: {
color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginLeft: "0.5rem",
},
[`& .matrixVersions`]: {
color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
fontSize: "0.8rem",
marginBottom: "1rem",
marginLeft: "0.5rem",
},
}));
const LoginPage = () => {
const login = useLogin();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
const [locale, setLocale] = useLocaleState();
const translate = useTranslate();
const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
if (loginToken) {
const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken);
// Prevent further requests
window.history.replaceState(
{},
"",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url");
if (baseUrl) {
const auth = {
base_url: baseUrl,
username: null,
password: null,
loginToken: ssoToken,
};
console.log("Base URL is:", baseUrl);
console.log("SSO Token is:", ssoToken);
console.log("Let's try token login...");
login(auth).catch(error => {
alert(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message
);
console.error(error);
});
}
}
const validateBaseUrl = value => {
if (!value.match(/^(http|https):\/\//)) {
return translate("synapseadmin.auth.protocol_error");
} else if (
!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)
) {
return translate("synapseadmin.auth.url_error");
} else {
return undefined;
}
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
{ type: "warning" }
);
});
};
const handleSSO = () => {
localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const UserData = ({ formData }) => {
const form = useFormContext();
const [serverVersion, setServerVersion] = useState("");
const [matrixVersions, setMatrixVersions] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordingly
const domain = splitMxid(formData.username)?.domain;
if (domain) {
getWellKnownUrl(domain).then(url => form.setValue("base_url", url));
}
};
useEffect(() => {
if (!isValidBaseUrl(formData.base_url)) return;
getServerVersion(formData.base_url)
.then(serverVersion =>
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${serverVersion}`
)
)
.catch(() => setServerVersion(""));
getSupportedFeatures(formData.base_url)
.then(features =>
setMatrixVersions(
`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`
)
)
.catch(() => setMatrixVersions(""));
// Set SSO Url
getSupportedLoginFlows(formData.base_url)
.then(loginFlows => {
const supportPass =
loginFlows.find(f => f.type === "m.login.password") !== undefined;
const supportSSO =
loginFlows.find(f => f.type === "m.login.sso") !== undefined;
setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? formData.base_url : "");
})
.catch(() => setSSOBaseUrl(""));
}, [formData.base_url]);
return (
<>
<Box>
<TextInput
autoFocus
name="username"
label="ra.auth.username"
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
className="input"
validate={required()}
/>
</Box>
<Box>
<PasswordInput
name="password"
label="ra.auth.password"
type="password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
className="input"
validate={required()}
/>
</Box>
<Box>
<TextInput
name="base_url"
label="synapseadmin.auth.base_url"
disabled={cfg_base_url || loading}
resettable
fullWidth
className="input"
validate={[required(), validateBaseUrl]}
/>
</Box>
<Typography className="serverVersion">{serverVersion}</Typography>
<Typography className="matrixVersions">{matrixVersions}</Typography>
</>
);
};
return (
<Form
defaultValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
mode="onTouched"
>
<FormBox>
<Card className="card">
<Box className="avatar">
{loading ? (
<CircularProgress size={25} thickness={2} />
) : (
<Avatar className="icon">
<LockIcon />
</Avatar>
)}
</Box>
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
value={locale}
onChange={e => {
setLocale(e.target.value);
}}
fullWidth
disabled={loading}
className="select"
>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="zh">简体中文</MenuItem>
<MenuItem value="fa">Persian(فارسی)</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
</Select>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
<CardActions className="actions">
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading || !supportPassAuth}
fullWidth
>
{translate("ra.auth.sign_in")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleSSO}
disabled={loading || ssoBaseUrl === ""}
fullWidth
>
{translate("synapseadmin.auth.sso_sign_in")}
</Button>
</CardActions>
</Box>
</Card>
</FormBox>
<Notification />
</Form>
);
};
export default LoginPage;

View File

@ -1,14 +1,14 @@
import React from "react";
import { render } from "@testing-library/react";
import { AdminContext } from "react-admin";
import { TestContext } from "ra-test";
import LoginPage from "./LoginPage";
describe("LoginForm", () => {
it("renders", () => {
render(
<AdminContext>
<TestContext>
<LoginPage />
</AdminContext>
</TestContext>
);
});
});

39
src/components/Menu.js Normal file
View File

@ -0,0 +1,39 @@
// in src/Menu.js
import * as React from "react";
import { useSelector } from "react-redux";
import { useMediaQuery } from "@material-ui/core";
import { MenuItemLink, getResources } from "react-admin";
import DefaultIcon from "@material-ui/icons/ViewList";
import LabelIcon from "@material-ui/icons/Label";
const Menu = ({ onMenuClick, logout }) => {
const isXSmall = useMediaQuery(theme => theme.breakpoints.down("xs"));
const open = useSelector(state => state.admin.ui.sidebarOpen);
const resources = useSelector(getResources);
return (
<div>
{resources.map(resource => (
<MenuItemLink
key={resource.name}
to={`/${resource.name}`}
primaryText={
(resource.options && resource.options.label) || resource.name
}
leftIcon={resource.icon ? <resource.icon /> : <DefaultIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
))}
<MenuItemLink
to="/custom-route"
primaryText="Miscellaneous"
leftIcon={<LabelIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
{isXSmall && logout}
</div>
);
};
export default Menu;

View File

@ -1,142 +0,0 @@
import React from "react";
import {
BooleanInput,
Create,
Datagrid,
DateField,
DateTimeInput,
Edit,
List,
maxValue,
number,
NumberField,
NumberInput,
regex,
SaveButton,
SimpleForm,
TextInput,
TextField,
Toolbar,
} from "react-admin";
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
const validateUsesAllowed = [number()];
const validateLength = [number(), maxValue(64)];
const dateParser = v => {
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const dateFormatter = v => {
if (v === undefined || v === null) return;
const d = new Date(v);
const pad = "00";
const year = d.getFullYear().toString();
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
const day = (pad + d.getDate().toString()).slice(-2);
const hour = (pad + d.getHours().toString()).slice(-2);
const minute = (pad + d.getMinutes().toString()).slice(-2);
// target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`;
};
const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
export const RegistrationTokenList = props => (
<List
{...props}
filters={registrationTokenFilters}
filterDefaultValues={{ valid: true }}
pagination={false}
perPage={500}
>
<Datagrid rowClick="edit">
<TextField source="token" sortable={false} />
<NumberField source="uses_allowed" sortable={false} />
<NumberField source="pending" sortable={false} />
<NumberField source="completed" sortable={false} />
<DateField
source="expiry_time"
showTime
options={date_format}
sortable={false}
/>
</Datagrid>
</List>
);
export const RegistrationTokenCreate = props => (
<Create {...props} redirect="list">
<SimpleForm
toolbar={
<Toolbar>
{/* It is possible to create tokens per default without input. */}
<SaveButton alwaysEnable />
</Toolbar>
}
>
<TextInput
source="token"
autoComplete="off"
validate={validateToken}
resettable
/>
<NumberInput
source="length"
validate={validateLength}
helperText="resources.registration_tokens.helper.length"
step={1}
/>
<NumberInput
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput source="expiry_time" parse={dateParser} />
</SimpleForm>
</Create>
);
export const RegistrationTokenEdit = props => (
<Edit {...props}>
<SimpleForm>
<TextInput source="token" disabled />
<NumberInput source="pending" disabled />
<NumberInput source="completed" disabled />
<NumberInput
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput
source="expiry_time"
parse={dateParser}
format={dateFormatter}
/>
</SimpleForm>
</Edit>
);
const resource = {
name: "registration_tokens",
icon: RegistrationTokenIcon,
list: RegistrationTokenList,
edit: RegistrationTokenEdit,
create: RegistrationTokenCreate,
};
export default resource;

View File

@ -0,0 +1,256 @@
import React, { Fragment } from "react";
import Avatar from "@material-ui/core/Avatar";
import { Chip } from "@material-ui/core";
import { connect } from "react-redux";
import FolderSharedIcon from "@material-ui/icons/FolderShared";
import { makeStyles } from "@material-ui/core/styles";
import {
BooleanField,
BulkDeleteButton,
Button,
Datagrid,
DeleteButton,
Filter,
List,
NumberField,
Pagination,
TextField,
useCreate,
useMutation,
useNotify,
useTranslate,
useRefresh,
useUnselectAll,
} from "react-admin";
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
});
const RoomDirectoryPagination = props => (
<Pagination {...props} rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryDeleteButton = props => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
};
export const RoomDirectoryBulkDeleteButton = props => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
undoable={false}
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
export const RoomDirectoryBulkSaveButton = ({ selectedIds }) => {
const notify = useNotify();
const refresh = useRefresh();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "room_directory",
payload: { ids: selectedIds, data: {} },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
unselectAll("rooms");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", "error"),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
export const RoomDirectorySaveButton = ({ record }) => {
const notify = useNotify();
const refresh = useRefresh();
const [create, { loading }] = useCreate("room_directory");
const handleSend = values => {
create(
{
payload: { data: { id: record.id } },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", "error"),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
const RoomDirectoryBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkDeleteButton {...props} />
</Fragment>
);
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
const RoomDirectoryFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<Chip
label={translate("resources.rooms.fields.room_id")}
source="room_id"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.topic")}
source="topic"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.canonical_alias")}
source="canonical_alias"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
export const FilterableRoomDirectoryList = ({
roomDirectoryFilters,
dispatch,
...props
}) => {
const classes = useStyles();
const translate = useTranslate();
const filter = roomDirectoryFilters;
const roomIdFilter = filter && filter.room_id ? true : false;
const topicFilter = filter && filter.topic ? true : false;
const canonicalAliasFilter = filter && filter.canonical_alias ? true : false;
return (
<List
{...props}
pagination={<RoomDirectoryPagination />}
bulkActionButtons={<RoomDirectoryBulkActionButtons />}
filters={<RoomDirectoryFilter />}
perPage={100}
>
<Datagrid>
<AvatarField
source="avatar_src"
sortable={false}
className={classes.small}
label={translate("resources.rooms.fields.avatar")}
/>
<TextField
source="name"
sortable={false}
label={translate("resources.rooms.fields.name")}
/>
{roomIdFilter && (
<TextField
source="room_id"
sortable={false}
label={translate("resources.rooms.fields.room_id")}
/>
)}
{canonicalAliasFilter && (
<TextField
source="canonical_alias"
sortable={false}
label={translate("resources.rooms.fields.canonical_alias")}
/>
)}
{topicFilter && (
<TextField
source="topic"
sortable={false}
label={translate("resources.rooms.fields.topic")}
/>
)}
<NumberField
source="num_joined_members"
sortable={false}
label={translate("resources.rooms.fields.joined_members")}
/>
<BooleanField
source="world_readable"
sortable={false}
label={translate("resources.room_directory.fields.world_readable")}
/>
<BooleanField
source="guest_can_join"
sortable={false}
label={translate("resources.room_directory.fields.guest_can_join")}
/>
</Datagrid>
</List>
);
};
function mapStateToProps(state) {
return {
roomDirectoryFilters:
state.admin.resources.room_directory.list.params.displayedFilters,
};
}
export const RoomDirectoryList = connect(mapStateToProps)(
FilterableRoomDirectoryList
);

View File

@ -1,206 +0,0 @@
import React from "react";
import {
BooleanField,
BulkDeleteButton,
Button,
DatagridConfigurable,
ExportButton,
DeleteButton,
List,
NumberField,
Pagination,
SelectColumnsButton,
TextField,
TopToolbar,
useCreate,
useDataProvider,
useListContext,
useNotify,
useTranslate,
useRecordContext,
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import AvatarField from "./AvatarField";
const RoomDirectoryPagination = () => (
<Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryUnpublishButton = props => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
};
export const RoomDirectoryBulkUnpublishButton = props => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
mutationMode="pessimistic"
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
export const RoomDirectoryBulkPublishButton = props => {
const { selectedIds } = useListContext();
const notify = useNotify();
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
() =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
return (
<Button
{...props}
label="resources.room_directory.action.create"
onClick={mutate}
disabled={isLoading}
>
<RoomDirectoryIcon />
</Button>
);
};
export const RoomDirectoryPublishButton = props => {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
const handleSend = () => {
create(
"room_directory",
{ data: { id: record.id } },
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
{...props}
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={isLoading}
>
<RoomDirectoryIcon />
</Button>
);
};
const RoomDirectoryListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
export const RoomDirectoryList = () => (
<List
pagination={<RoomDirectoryPagination />}
perPage={100}
actions={<RoomDirectoryListActions />}
>
<DatagridConfigurable
rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]}
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar"
/>
<TextField
source="name"
sortable={false}
label="resources.rooms.fields.name"
/>
<TextField
source="room_id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<TextField
source="canonical_alias"
sortable={false}
label="resources.rooms.fields.canonical_alias"
/>
<TextField
source="topic"
sortable={false}
label="resources.rooms.fields.topic"
/>
<NumberField
source="num_joined_members"
sortable={false}
label="resources.rooms.fields.joined_members"
/>
<BooleanField
source="world_readable"
sortable={false}
label="resources.room_directory.fields.world_readable"
/>
<BooleanField
source="guest_can_join"
sortable={false}
label="resources.room_directory.fields.guest_can_join"
/>
</DatagridConfigurable>
</List>
);
const resource = {
name: "room_directory",
icon: RoomDirectoryIcon,
list: RoomDirectoryList,
};
export default resource;

View File

@ -0,0 +1,35 @@
import React, { useCallback } from "react";
import { SaveButton, useCreate, useRedirect, useNotify } from "react-admin";
const SaveQrButton = props => {
const [create] = useCreate("users");
const redirectTo = useRedirect();
const notify = useNotify();
const { basePath } = props;
const handleSave = useCallback(
(values, redirect) => {
create(
{
payload: { data: { ...values } },
},
{
onSuccess: ({ data: newRecord }) => {
notify("ra.notification.created", "info", {
smart_count: 1,
});
redirectTo(redirect, basePath, newRecord.id, {
password: values.password,
...newRecord,
});
},
}
);
},
[create, notify, redirectTo, basePath]
);
return <SaveButton {...props} onSave={handleSave} />;
};
export default SaveQrButton;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { Fragment, useState } from "react";
import {
Button,
SaveButton,
@ -7,32 +7,24 @@ import {
Toolbar,
required,
useCreate,
useDataProvider,
useListContext,
useMutation,
useNotify,
useRecordContext,
useTranslate,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import MessageIcon from "@mui/icons-material/Message";
import IconCancel from "@mui/icons-material/Cancel";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import MessageIcon from "@material-ui/icons/Message";
import IconCancel from "@material-ui/icons/Cancel";
import Dialog from "@material-ui/core/Dialog";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const ServerNoticeToolbar = props => (
<Toolbar {...props}>
<SaveButton
label="resources.servernotices.action.send"
disabled={props.pristine}
/>
<SaveButton label="resources.servernotices.action.send" />
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
@ -48,7 +40,12 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
<DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
<SimpleForm
toolbar={<ServerNoticeToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<TextInput
source="body"
label="resources.servernotices.fields.body"
@ -64,92 +61,88 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
);
};
export const ServerNoticeButton = () => {
const record = useRecordContext();
export const ServerNoticeButton = ({ record }) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const [create, { isloading }] = useCreate();
const [create, { loading }] = useCreate("servernotices");
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
create(
"servernotices",
{ data: { id: record.id, ...values } },
{ payload: { data: { id: record.id, ...values } } },
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
handleDialogClose();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.servernotices.action.send_failure", "error"),
}
);
};
return (
<>
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={isloading}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSubmit={handleSend}
onSend={handleSend}
/>
</>
</Fragment>
);
};
export const ServerNoticeBulkButton = () => {
const { selectedIds } = useListContext();
export const ServerNoticeBulkButton = ({ selectedIds }) => {
const [open, setOpen] = useState(false);
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const notify = useNotify();
const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const { mutate: sendNotices, isLoading } = useMutation(
data =>
dataProvider.createMany("servernotices", {
ids: selectedIds,
data: data,
}),
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "servernotices",
payload: { ids: selectedIds, data: values },
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
{
onSuccess: ({ data }) => {
notify("resources.servernotices.action.send_success");
unselectAll("users");
handleDialogClose();
},
onFailure: error =>
notify("resources.servernotices.action.send_failure", "error"),
}
);
};
return (
<>
<Fragment>
<Button
label="resources.servernotices.send"
onClick={openDialog}
disabled={isLoading}
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={closeDialog}
onSubmit={sendNotices}
onClose={handleDialogClose}
onSend={handleSend}
/>
</>
</Fragment>
);
};

View File

@ -0,0 +1,263 @@
import React, { useRef } from "react";
import { Title, Button } from "react-admin";
import { makeStyles } from "@material-ui/core/styles";
import { PDFExport } from "@progress/kendo-react-pdf";
import QRCode from "qrcode.react";
import { string, any } from "prop-types";
function xor(a, b) {
var res = "";
for (var i = 0; i < a.length; i++) {
res += String.fromCharCode(a.charCodeAt(i) ^ b.charCodeAt(i % b.length));
}
return res;
}
function calculateQrString(serverUrl, username, password) {
const magicString = "wo9k5tep252qxsa5yde7366kugy6c01w7oeeya9hrmpf0t7ii7";
const origUrlString = "user=" + username + "&password=" + password;
var urlString = xor(origUrlString, magicString); // xor with magic string
if (origUrlString !== xor(urlString, magicString)) {
console.error(
"xoring this url string with magicString twice gave different results:",
origUrlString,
urlString,
xor(urlString, magicString)
);
}
urlString = btoa(urlString); // to base64
return serverUrl + "/#" + urlString;
}
UserPdfPage.propTypes = {
classes: any,
displayname: string,
qrCode: any,
serverUrl: string,
username: string,
password: string,
};
function UserPdfPage({
classes,
displayname,
qrCode,
serverUrl,
username,
password,
}) {
return (
<div className={classes.page}>
<div className={classes.header}>
<div className={classes.name}>{displayname}</div>
<img className={classes.logo} alt="Logo" src="images/logo.png" />
</div>
<div className={classes.body}>
<table>
<tbody>
<tr>
<td width="200px">
<div className={classes.code_note}>
Ihr persönlicher Anmeldecode:
</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_note}>
Ihre persönlichen Zugangsdaten:
</div>
</td>
</tr>
<tr>
<td>
<div className={classes.qr}>{qrCode}</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_text}>
<br />
<table>
<tbody>
<tr>
<td>Heimserver:</td>
<td>
<span className={classes.credentials}>
{serverUrl}
</span>
</td>
</tr>
<tr>
<td>Benutzername:</td>
<td>
<span className={classes.credentials}>
{username}
</span>
</td>
</tr>
<tr>
<td>Passwort:</td>
<td>
<span className={classes.credentials}>
{password}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
<div className={classes.note}>
Hier können Sie Ihre selbst gewählte Schlüsselsicherungs-Passphrase
notieren:
<br />
<br />
<br />
<hr />
</div>
</div>
</div>
);
}
const useStyles = makeStyles(theme => ({
page: {
height: 800,
width: 566,
padding: "none",
backgroundColor: "white",
boxShadow: "5px 5px 5px black",
margin: "auto",
overflowX: "hidden",
overflowY: "hidden",
fontFamily: "DejaVu Sans, Sans-Serif",
fontSize: 15,
},
header: {
height: 144,
width: 534,
marginLeft: 32,
marginTop: 15,
},
name: {
width: 240,
fontSize: 35,
float: "left",
marginTop: 100,
},
logo: {
width: 90,
marginTop: 50,
marginRight: 70,
float: "right",
},
body: {
clear: "both",
},
table_cell: {
verticalAlign: "top",
},
code_note: {
marginLeft: 32,
marginTop: 86,
},
qr: {
marginTop: 15,
marginLeft: 32,
},
credentials_note: {
marginTop: 86,
marginLeft: 10,
},
credentials_text: {
marginLeft: 10,
fontSize: 12,
},
credentials: {
fontFamily: "DejaVu Sans Mono, monospace",
},
note: {
fontSize: 18,
marginTop: 100,
marginLeft: 32,
marginRight: 32,
},
}));
const ShowUserPdf = props => {
const classes = useStyles();
const userPdf = useRef(null);
const exportPDF = () => {
userPdf.current.save();
};
let userRecords;
if (props.records) {
userRecords = props.records;
}
if (
props.location &&
props.location.state &&
props.location.state.id &&
props.location.state.password
) {
userRecords = [
{
id: props.location.state.id,
password: props.location.state.password,
displayname: props.location.state.displayname,
},
];
}
return (
<div>
<Title title="PDF" />
<Button label="synapseadmin.action.download_pdf" onClick={exportPDF} />
<PDFExport
paperSize={"A4"}
fileName="Users.pdf"
title=""
subject=""
keywords=""
ref={userPdf}
//ref={r => (resume = r)}
>
{userRecords.map(record => {
if (record.id && record.password) {
const username = record.id.substring(1, record.id.indexOf(":"));
const serverUrl =
"https://" + record.id.substring(record.id.indexOf(":") + 1);
const qrString = calculateQrString(
serverUrl,
username,
record.password
);
const qrCode = <QRCode value={qrString} size={128} />;
return (
<UserPdfPage
classes={classes}
displayname={record.displayname}
qrCode={qrCode}
serverUrl={serverUrl}
username={username}
password={record.password}
/>
);
} else {
/* Skip empty PDF pages */
return null;
}
})}
</PDFExport>
</div>
);
};
export default ShowUserPdf;

View File

@ -1,189 +0,0 @@
import React from "react";
import {
Button,
Datagrid,
DateField,
List,
Pagination,
ReferenceField,
ReferenceManyField,
SearchInput,
Show,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useDelete,
useNotify,
useRefresh,
useTranslate,
} from "react-admin";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
const DestinationPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const destinationRowSx = (record, _index) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
});
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
export const DestinationReconnectButton = () => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [handleReconnect, { isLoading }] = useDelete();
// Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null;
const handleClick = e => {
// Prevents redirection to the detail page when clicking in the list
e.stopPropagation();
handleReconnect(
"destinations",
{ id: record.id },
{
onSuccess: () => {
notify("ra.notification.updated", {
messageArgs: { smart_count: 1 },
});
refresh();
},
onError: () => {
notify("ra.message.error", { type: "error" });
},
}
);
};
return (
<Button
label="resources.destinations.action.reconnect"
onClick={handleClick}
disabled={isLoading}
>
<AutorenewIcon />
</Button>
);
};
const DestinationShowActions = () => (
<TopToolbar>
<DestinationReconnectButton />
</TopToolbar>
);
const DestinationTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.destinations.name", 1)} {record.destination}
</span>
);
};
export const DestinationList = props => {
return (
<List
{...props}
filters={destinationFilters}
pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }}
>
<Datagrid
rowSx={destinationRowSx}
rowClick={(id, _resource, _record) => `${id}/show/rooms`}
bulkActionButtons={false}
>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} />
<DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton />
</Datagrid>
</List>
);
};
export const DestinationShow = props => {
const translate = useTranslate();
return (
<Show
actions={<DestinationShowActions />}
title={<DestinationTitle />}
{...props}
>
<TabbedShowLayout>
<Tab label="status" icon={<ViewListIcon />}>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} />
<DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
</Tab>
<Tab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<FolderSharedIcon />}
path="rooms"
>
<ReferenceManyField
reference="destination_rooms"
target="destination"
addLabel={false}
pagination={<DestinationPagination />}
perPage={50}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => `/rooms/${id}/show`}
>
<TextField
source="room_id"
label="resources.rooms.fields.room_id"
/>
<TextField source="stream_ordering" sortable={false} />
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const resource = {
name: "destinations",
icon: DestinationsIcon,
list: DestinationList,
show: DestinationShow,
};
export default resource;

89
src/components/devices.js Normal file
View File

@ -0,0 +1,89 @@
import React, { Fragment, useState } from "react";
import {
Button,
useMutation,
useNotify,
Confirm,
useRefresh,
} from "react-admin";
import ActionDelete from "@material-ui/icons/Delete";
import { makeStyles } from "@material-ui/core/styles";
import { fade } from "@material-ui/core/styles/colorManipulator";
import classnames from "classnames";
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
export const DeviceRemoveButton = props => {
const { record } = props;
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice, { loading }] = useMutation();
if (!record) return null;
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleConfirm = () => {
removeDevice(
{
type: "delete",
resource: "devices",
payload: {
id: record.id,
user_id: record.user_id,
},
},
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onFailure: () =>
notify("resources.devices.action.erase.failure", "error"),
}
);
setOpen(false);
};
return (
<Fragment>
<Button
label="ra.action.remove"
onClick={handleClick}
className={classnames("ra-delete-button", classes.deleteButton)}
>
<ActionDelete />
</Button>
<Confirm
isOpen={open}
loading={loading}
onConfirm={handleConfirm}
onClose={handleDialogClose}
title="resources.devices.action.erase.title"
content="resources.devices.action.erase.content"
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
</Fragment>
);
};

View File

@ -1,51 +0,0 @@
import React from "react";
import {
DeleteButton,
useDelete,
useNotify,
useRecordContext,
useRefresh,
} from "react-admin";
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice] = useDelete();
if (!record) return null;
const handleConfirm = () => {
removeDevice(
"devices",
// needs previousData for user_id
{ id: record.id, previousData: record },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onError: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
};
return (
<DeleteButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
onConfirm={handleConfirm}
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

View File

@ -1,4 +1,8 @@
import React, { useState } from "react";
import React, { Fragment, useState } from "react";
import classnames from "classnames";
import { fade } from "@material-ui/core/styles/colorManipulator";
import { makeStyles } from "@material-ui/core/styles";
import { Tooltip } from "@material-ui/core";
import {
BooleanInput,
Button,
@ -10,26 +14,37 @@ import {
useCreate,
useDelete,
useNotify,
useRecordContext,
useRefresh,
useTranslate,
} from "react-admin";
import BlockIcon from "@mui/icons-material/Block";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip,
} from "@mui/material";
import IconCancel from "@mui/icons-material/Cancel";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { alpha, useTheme } from "@mui/material/styles";
import BlockIcon from "@material-ui/icons/Block";
import ClearIcon from "@material-ui/icons/Clear";
import DeleteSweepIcon from "@material-ui/icons/DeleteSweep";
import Dialog from "@material-ui/core/Dialog";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
import IconCancel from "@material-ui/icons/Cancel";
import LockIcon from "@material-ui/icons/Lock";
import LockOpenIcon from "@material-ui/icons/LockOpen";
const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const dateParser = v => {
@ -38,17 +53,19 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
return d.getTime();
};
const DeleteMediaToolbar = props => (
<Toolbar {...props}>
<SaveButton
label="resources.delete_media.action.send"
icon={<DeleteSweepIcon />}
/>
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
const DeleteMediaToolbar = props => {
return (
<Toolbar {...props}>
<SaveButton
label="resources.delete_media.action.send"
icon={<DeleteSweepIcon />}
/>
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
};
return (
<Dialog open={open} onClose={onClose} loading={loading}>
@ -59,7 +76,12 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
<DialogContentText>
{translate("resources.delete_media.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<SimpleForm
toolbar={<DeleteMediaToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<DateTimeInput
fullWidth
source="before_ts"
@ -88,101 +110,81 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
};
export const DeleteMediaButton = props => {
const theme = useTheme();
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const notify = useNotify();
const [deleteOne, { isLoading }] = useDelete();
const [deleteOne, { loading }] = useDelete("delete_media");
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const deleteMedia = values => {
const handleSend = values => {
deleteOne(
"delete_media",
// needs meta.before_ts, meta.size_gt and meta.keep_profiles
{ meta: values },
{ payload: { ...values } },
{
onSuccess: () => {
notify("resources.delete_media.action.send_success");
closeDialog();
handleDialogClose();
},
onError: () =>
notify("resources.delete_media.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.delete_media.action.send_failure", "error"),
}
);
};
return (
<>
<Fragment>
<Button
{...props}
label="resources.delete_media.action.send"
onClick={openDialog}
disabled={isLoading}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
}}
onClick={handleDialogOpen}
disabled={loading}
className={classnames("ra-delete-button", classes.deleteButton)}
>
<DeleteSweepIcon />
</Button>
<DeleteMediaDialog
open={open}
onClose={closeDialog}
onSubmit={deleteMedia}
onClose={handleDialogClose}
onSend={handleSend}
/>
</>
</Fragment>
);
};
export const ProtectMediaButton = () => {
const record = useRecordContext();
export const ProtectMediaButton = props => {
const { record } = props;
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
const [create, { loading }] = useCreate("protect_media");
const [deleteOne] = useDelete("protect_media");
if (!record) return null;
const handleProtect = () => {
create(
"protect_media",
{ data: record },
{ payload: { data: record } },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onError: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.protect_media.action.send_failure", "error"),
}
);
};
const handleUnprotect = () => {
deleteOne(
"protect_media",
{ id: record.id },
{ payload: { ...record } },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onError: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.protect_media.action.send_failure", "error"),
}
);
};
@ -192,7 +194,7 @@ export const ProtectMediaButton = () => {
Wrapping Tooltip with <div>
https://github.com/marmelab/react-admin/issues/4349#issuecomment-578594735
*/
<>
<Fragment>
{record.quarantined_by && (
<Tooltip
title={translate("resources.protect_media.action.none", {
@ -218,7 +220,7 @@ export const ProtectMediaButton = () => {
arrow
>
<div>
<Button onClick={handleUnprotect} disabled={isLoading}>
<Button onClick={handleUnprotect} disabled={loading}>
<LockIcon />
</Button>
</div>
@ -231,62 +233,56 @@ export const ProtectMediaButton = () => {
})}
>
<div>
<Button onClick={handleProtect} disabled={isLoading}>
<Button onClick={handleProtect} disabled={loading}>
<LockOpenIcon />
</Button>
</div>
</Tooltip>
)}
</>
</Fragment>
);
};
export const QuarantineMediaButton = props => {
const record = useRecordContext();
const { record } = props;
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
const [create, { loading }] = useCreate("quarantine_media");
const [deleteOne] = useDelete("quarantine_media");
if (!record) return null;
const handleQuarantaine = () => {
create(
"quarantine_media",
{ data: record },
{ payload: { data: record } },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onError: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.quarantine_media.action.send_failure", "error"),
}
);
};
const handleRemoveQuarantaine = () => {
deleteOne(
"quarantine_media",
{ id: record.id, previousData: record },
{ payload: { ...record } },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onError: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
onFailure: () =>
notify("resources.quarantine_media.action.send_failure", "error"),
}
);
};
return (
<>
<Fragment>
{record.safe_from_quarantine && (
<Tooltip
title={translate("resources.quarantine_media.action.none", {
@ -294,7 +290,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button {...props} disabled={true}>
<Button disabled={true}>
<ClearIcon />
</Button>
</div>
@ -307,11 +303,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button
{...props}
onClick={handleRemoveQuarantaine}
disabled={isLoading}
>
<Button onClick={handleRemoveQuarantaine} disabled={loading}>
<BlockIcon color="error" />
</Button>
</div>
@ -324,12 +316,12 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button onClick={handleQuarantaine} disabled={isLoading}>
<Button onClick={handleQuarantaine} disabled={loading}>
<BlockIcon />
</Button>
</div>
</Tooltip>
)}
</>
</Fragment>
);
};

784
src/components/rooms.js Normal file
View File

@ -0,0 +1,784 @@
import React, { Fragment } from "react";
import { connect } from "react-redux";
import { Route, Link } from "react-router-dom";
import {
AutocompleteArrayInput,
AutocompleteInput,
BooleanInput,
BooleanField,
BulkDeleteButton,
Button,
Create,
Edit,
Datagrid,
DateField,
DeleteButton,
Filter,
FormTab,
List,
NumberField,
Pagination,
ReferenceArrayInput,
ReferenceField,
ReferenceInput,
ReferenceManyField,
SearchInput,
SelectField,
Show,
SimpleForm,
Tab,
TabbedForm,
TabbedShowLayout,
TextField,
TextInput,
Toolbar,
TopToolbar,
useDataProvider,
useRecordContext,
useRefresh,
useTranslate,
} from "react-admin";
import get from "lodash/get";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import {
Tooltip,
Typography,
Chip,
Drawer,
styled,
withStyles,
Select,
MenuItem,
} from "@material-ui/core";
import FastForwardIcon from "@material-ui/icons/FastForward";
import HttpsIcon from "@material-ui/icons/Https";
import NoEncryptionIcon from "@material-ui/icons/NoEncryption";
import PageviewIcon from "@material-ui/icons/Pageview";
import UserIcon from "@material-ui/icons/Group";
import ViewListIcon from "@material-ui/icons/ViewList";
import VisibilityIcon from "@material-ui/icons/Visibility";
import ContentSave from "@material-ui/icons/Save";
import EventIcon from "@material-ui/icons/Event";
import {
RoomDirectoryBulkDeleteButton,
RoomDirectoryBulkSaveButton,
RoomDirectoryDeleteButton,
RoomDirectorySaveButton,
} from "./RoomDirectory";
const useStyles = makeStyles(theme => ({
helper_forward_extremities: {
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
},
}));
const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const EncryptionField = ({ source, record = {}, emptyText }) => {
const translate = useTranslate();
const value = get(record, source);
let ariaLabel = value === false ? "ra.boolean.false" : "ra.boolean.true";
if (value === false || value === true) {
return (
<Typography component="span" variant="body2">
<Tooltip title={translate(ariaLabel, { _: ariaLabel })}>
{value === true ? (
<HttpsIcon data-testid="true" htmlColor="limegreen" />
) : (
<NoEncryptionIcon data-testid="false" color="error" />
)}
</Tooltip>
</Typography>
);
}
return (
<Typography component="span" variant="body2">
{emptyText}
</Typography>
);
};
const validateDisplayName = fieldval => {
return fieldval == null
? "synapseadmin.rooms.room_name_required"
: fieldval.length === 0
? "synapseadmin.rooms.room_name_required"
: undefined;
};
function approximateAliasLength(alias, homeserver) {
/* TODO maybe handle punycode in homeserver name */
var te;
// Support for TextEncoder is quite widespread, but the polyfill is
// pretty large; We will only underestimate the size with the regular
// length attribute of String, so we never prevent the user from using
// an alias that is short enough for the server, but too long for our
// heuristic.
try {
te = new TextEncoder();
} catch (err) {
if (err instanceof ReferenceError) {
te = undefined;
}
}
const aliasLength = te === undefined ? alias.length : te.encode(alias).length;
return "#".length + aliasLength + ":".length + homeserver.length;
}
const validateAlias = fieldval => {
if (fieldval === undefined) {
return undefined;
}
const homeserver = localStorage.getItem("home_server");
if (approximateAliasLength(fieldval, homeserver) > 255) {
return "synapseadmin.rooms.alias_too_long";
}
};
const removeLeadingWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.trimStart();
const replaceAllWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.replace(/\s/, "_");
const removeLeadingSigil = fieldVal =>
fieldVal === undefined
? undefined
: fieldVal.startsWith("#")
? fieldVal.substr(1)
: fieldVal;
const validateHasAliasIfPublic = formdata => {
let errors = {};
if (formdata.public) {
if (
formdata.canonical_alias === undefined ||
formdata.canonical_alias.trim().length === 0
) {
errors.canonical_alias = "synapseadmin.rooms.alias_required_if_public";
}
}
return errors;
};
export const RoomCreate = props => (
<Create {...props}>
<TabbedForm validate={validateHasAliasIfPublic}>
<FormTab label="synapseadmin.rooms.details" icon={<ViewListIcon />}>
<TextInput
source="name"
parse={removeLeadingWhitespace}
validate={validateDisplayName}
/>
<TextInput
source="canonical_alias"
parse={fv => replaceAllWhitespace(removeLeadingSigil(fv))}
validate={validateAlias}
placeholder="#"
/>
<ReferenceInput
reference="users"
source="owner"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceInput>
<BooleanInput source="public" label="synapseadmin.rooms.make_public" />
<BooleanInput
source="encrypt"
initialValue={true}
label="synapseadmin.rooms.encrypt"
/>
</FormTab>
<FormTab
label="resources.rooms.fields.invite_members"
icon={<UserIcon />}
>
<ReferenceArrayInput
reference="users"
source="invitees"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteArrayInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceArrayInput>
</FormTab>
</TabbedForm>
</Create>
);
const RoomTitle = ({ record }) => {
const translate = useTranslate();
return (
<span>
{translate("resources.rooms.name", 1)} {record ? `"${record.name}"` : ""}
</span>
);
};
// Explicitely passing "to" prop
// Toolbar adds all kinds of unsupported props to its children :(
const StyledLink = styles => {
const Styled = styled(Link)(styles);
return ({ to, children }) => <Styled to={to}>{children}</Styled>;
};
const RoomMemberEditToolbar = ({ backLink, translate, onSave, ...props }) => {
const SaveLink = StyledLink({
textDecoration: "none",
});
const CancelLink = StyledLink({
textDecoration: "none",
marginLeft: "1em",
});
const SaveIcon = styled(ContentSave)({
width: "1rem",
marginRight: "0.25em",
});
return (
<Toolbar {...props}>
<SaveLink to={backLink}>
<Button onClick={onSave} variant="contained">
<React.Fragment>
<SaveIcon />
{translate("ra.action.save")}
</React.Fragment>
</Button>
</SaveLink>
<CancelLink to={backLink}>
<Button>
<React.Fragment>{translate("ra.action.cancel")}</React.Fragment>
</Button>
</CancelLink>
</Toolbar>
);
};
const RoomMemberIdField = ({ memberId, data = {} }) => {
const value = get(data[memberId], "id");
return (
<Typography component="span" variant="body2">
{value}
</Typography>
);
};
const RoomMemberRoleInput = ({ memberId, data = {}, translate, onChange }) => {
const roleValue = get(data[memberId], "role");
const [role, setRole] = React.useState(roleValue);
React.useEffect(() => {
onChange(roleValue);
}, [onChange, roleValue]);
return (
<React.Fragment>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={role}
onChange={event => {
setRole(event.target.value);
onChange(event.target.value);
}}
>
<MenuItem value={"user"}>
{translate("resources.users.roles.user")}
</MenuItem>
<MenuItem value={"mod"}>
{translate("resources.users.roles.mod")}
</MenuItem>
<MenuItem value={"admin"}>
{translate("resources.users.roles.admin")}
</MenuItem>
</Select>
</React.Fragment>
);
};
const RoomMemberEdit = ({ backLink, memberId, ...props }) => {
const translate = useTranslate();
const refresh = useRefresh();
const dataProvider = useDataProvider();
const [role, setRole] = React.useState();
const { id } = props;
return (
<Edit title=" " {...props}>
<SimpleForm
toolbar={
<RoomMemberEditToolbar
backLink={backLink}
translate={translate}
onSave={() => {
dataProvider
.update("rooms", {
data: {
id,
member_roles: [{ member_id: memberId, role }],
},
})
.then(() => {
refresh();
});
}}
/>
}
>
<ReferenceManyField
reference="room_members"
target="room_id"
label="resources.users.fields.id"
>
<RoomMemberIdField memberId={memberId} />
</ReferenceManyField>
<ReferenceManyField
reference="room_members"
target="room_id"
label="resources.users.fields.role"
>
<RoomMemberRoleInput
memberId={memberId}
translate={translate}
onChange={setRole}
/>
</ReferenceManyField>
</SimpleForm>
</Edit>
);
};
const drawerStyles = {
paper: {
width: 300,
},
};
const StyledDrawer = withStyles(drawerStyles)(({ classes, ...props }) => (
<Drawer {...props} classes={classes} />
));
export const RoomEdit = props => {
const translate = useTranslate();
return (
<React.Fragment>
<Edit {...props} title={<RoomTitle />}>
<TabbedForm>
<FormTab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />}>
<ReferenceArrayInput
reference="users"
source="invitees"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteArrayInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceArrayInput>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) =>
`/rooms/${encodeURIComponent(
record.parentId
)}/${encodeURIComponent(id)}`
}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
<SelectField
source="role"
label="resources.users.fields.role"
choices={[
{
id: "user",
name: translate("resources.users.roles.user"),
},
{ id: "mod", name: translate("resources.users.roles.mod") },
{
id: "admin",
name: translate("resources.users.roles.admin"),
},
]}
/>
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
<Route path="/rooms/:roomId/:memberId">
{({ match }) => {
const isMatch = !!match && !!match.params;
return (
<StyledDrawer open={isMatch} anchor="right">
{isMatch ? (
<RoomMemberEdit
{...props}
memberId={
isMatch ? decodeURIComponent(match.params.memberId) : null
}
backLink={`/rooms/${match.params.roomId}`}
/>
) : (
<div />
)}
</StyledDrawer>
);
}}
</Route>
</React.Fragment>
);
};
const RoomShowActions = ({ basePath, data, resource }) => {
var roomDirectoryStatus = "";
if (data) {
roomDirectoryStatus = data.public;
}
return (
<TopToolbar>
{roomDirectoryStatus === false && (
<RoomDirectorySaveButton record={data} />
)}
{roomDirectoryStatus === true && (
<RoomDirectoryDeleteButton record={data} />
)}
<DeleteButton
basePath={basePath}
record={data}
resource={resource}
mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
/>
</TopToolbar>
);
};
export const RoomShow = props => {
const classes = useStyles({ props });
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users">
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="joined_local_devices" />
<TextField source="state_events" />
<TextField source="version" />
<TextField
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab>
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />}>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => "/users/" + id}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" />
<BooleanField source="public" />
<SelectField
source="join_rules"
choices={[
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
{
id: "private",
name: "resources.rooms.enums.join_rules.private",
},
]}
/>
<SelectField
source="guest_access"
choices={[
{
id: "can_join",
name: "resources.rooms.enums.guest_access.can_join",
},
{
id: "forbidden",
name: "resources.rooms.enums.guest_access.forbidden",
},
]}
/>
<SelectField
source="history_visibility"
choices={[
{
id: "invited",
name: "resources.rooms.enums.history_visibility.invited",
},
{
id: "joined",
name: "resources.rooms.enums.history_visibility.joined",
},
{
id: "shared",
name: "resources.rooms.enums.history_visibility.shared",
},
{
id: "world_readable",
name: "resources.rooms.enums.history_visibility.world_readable",
},
]}
/>
</Tab>
<Tab
label={translate("resources.room_state.name", { smart_count: 2 })}
icon={<EventIcon />}
path="state"
>
<ReferenceManyField
reference="room_state"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="type" sortable={false} />
<DateField
source="origin_server_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<TextField source="content" sortable={false} />
<ReferenceField
source="sender"
reference="users"
sortable={false}
>
<TextField source="id" />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="resources.forward_extremities.name"
icon={<FastForwardIcon />}
path="forward_extremities"
>
<div className={classes.helper_forward_extremities}>
{translate("resources.rooms.helper.forward_extremities")}
</div>
<ReferenceManyField
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} />
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const RoomBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkSaveButton {...props} />
<RoomDirectoryBulkDeleteButton {...props} />
<BulkDeleteButton
{...props}
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
undoable={false}
/>
</Fragment>
);
const RoomFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
<Chip
label={translate("resources.rooms.fields.joined_local_members")}
source="joined_local_members"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.state_events")}
source="state_events"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.version")}
source="version"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.federatable")}
source="federatable"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
const RoomNameField = props => {
const { source } = props;
const record = useRecordContext(props);
return (
<span>{record[source] || record["canonical_alias"] || record["id"]}</span>
);
};
RoomNameField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired,
};
const FilterableRoomList = ({ roomFilters, dispatch, ...props }) => {
const filter = roomFilters;
const localMembersFilter =
filter && filter.joined_local_members ? true : false;
const stateEventsFilter = filter && filter.state_events ? true : false;
const versionFilter = filter && filter.version ? true : false;
const federateableFilter = filter && filter.federatable ? true : false;
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={<RoomFilter />}
bulkActionButtons={<RoomBulkActionButtons />}
>
<Datagrid rowClick="show">
<EncryptionField
source="is_encrypted"
sortBy="encryption"
label={<HttpsIcon />}
/>
<RoomNameField source="name" />
<TextField source="joined_members" />
{localMembersFilter && <TextField source="joined_local_members" />}
{stateEventsFilter && <TextField source="state_events" />}
{versionFilter && <TextField source="version" />}
{federateableFilter && <BooleanField source="federatable" />}
<BooleanField source="public" />
</Datagrid>
</List>
);
};
function mapStateToProps(state) {
return {
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
};
}
export const RoomList = connect(mapStateToProps)(FilterableRoomList);

View File

@ -1,355 +0,0 @@
import React from "react";
import {
BooleanField,
BulkDeleteButton,
DateField,
Datagrid,
DatagridConfigurable,
DeleteButton,
ExportButton,
FunctionField,
List,
NumberField,
Pagination,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectColumnsButton,
SelectField,
Show,
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import FastForwardIcon from "@mui/icons-material/FastForward";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
import PageviewIcon from "@mui/icons-material/Pageview";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EventIcon from "@mui/icons-material/Event";
import RoomIcon from "@mui/icons-material/ViewList";
import {
RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkPublishButton,
RoomDirectoryUnpublishButton,
RoomDirectoryPublishButton,
} from "./RoomDirectory";
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const RoomPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const RoomTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
var name = "";
if (record) {
name = record.name !== "" ? record.name : record.id;
}
return (
<span>
{translate("resources.rooms.name", 1)} {name}
</span>
);
};
const RoomShowActions = () => {
const record = useRecordContext();
var roomDirectoryStatus = "";
if (record) {
roomDirectoryStatus = record.public;
}
return (
<TopToolbar>
{roomDirectoryStatus === false && <RoomDirectoryPublishButton />}
{roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
<DeleteButton
mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
/>
</TopToolbar>
);
};
export const RoomShow = props => {
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="topic" />
<TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users">
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="joined_local_devices" />
<TextField source="state_events" />
<TextField source="version" />
<TextField
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.members"
icon={<UserIcon />}
path="members"
>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/users/" + id}
bulkActionButtons={false}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" />
<BooleanField source="public" />
<SelectField
source="join_rules"
choices={[
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
{
id: "private",
name: "resources.rooms.enums.join_rules.private",
},
]}
/>
<SelectField
source="guest_access"
choices={[
{
id: "can_join",
name: "resources.rooms.enums.guest_access.can_join",
},
{
id: "forbidden",
name: "resources.rooms.enums.guest_access.forbidden",
},
]}
/>
<SelectField
source="history_visibility"
choices={[
{
id: "invited",
name: "resources.rooms.enums.history_visibility.invited",
},
{
id: "joined",
name: "resources.rooms.enums.history_visibility.joined",
},
{
id: "shared",
name: "resources.rooms.enums.history_visibility.shared",
},
{
id: "world_readable",
name: "resources.rooms.enums.history_visibility.world_readable",
},
]}
/>
</Tab>
<Tab
label={translate("resources.room_state.name", { smart_count: 2 })}
icon={<EventIcon />}
path="state"
>
<ReferenceManyField
reference="room_state"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="type" sortable={false} />
<DateField
source="origin_server_ts"
showTime
options={date_format}
sortable={false}
/>
<TextField source="content" sortable={false} />
<ReferenceField
source="sender"
reference="users"
sortable={false}
>
<TextField source="id" />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="resources.forward_extremities.name"
icon={<FastForwardIcon />}
path="forward_extremities"
>
<Box
sx={{
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
}}
>
{translate("resources.rooms.helper.forward_extremities")}
</Box>
<ReferenceManyField
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={date_format}
sortable={false}
/>
<NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} />
</Datagrid>
</ReferenceManyField>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const RoomBulkActionButtons = () => (
<>
<RoomDirectoryBulkPublishButton />
<RoomDirectoryBulkUnpublishButton />
<BulkDeleteButton
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
mutationMode="pessimistic"
/>
</>
);
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
const RoomListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
export const RoomList = props => {
const theme = useTheme();
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={roomFilters}
actions={<RoomListActions />}
>
<DatagridConfigurable
rowClick="show"
bulkActionButtons={<RoomBulkActionButtons />}
omit={[
"joined_local_members",
"state_events",
"version",
"federatable",
]}
>
<BooleanField
source="is_encrypted"
sortBy="encryption"
TrueIcon={HttpsIcon}
FalseIcon={NoEncryptionIcon}
label={<HttpsIcon />}
sx={{
[`& [data-testid="true"]`]: { color: theme.palette.success.main },
[`& [data-testid="false"]`]: { color: theme.palette.error.main },
}}
/>
<FunctionField
source="name"
render={record =>
record["name"] || record["canonical_alias"] || record["id"]
}
/>
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="state_events" />
<TextField source="version" />
<BooleanField source="federatable" />
<BooleanField source="public" />
</DatagridConfigurable>
</List>
);
};
const resource = {
name: "rooms",
icon: RoomIcon,
list: RoomList,
show: RoomShow,
};
export default resource;

View File

@ -0,0 +1,81 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
Filter,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const {
currentSort,
resource,
displayedFilters,
filterValues,
showFilter,
total,
} = useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={currentSort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserMediaStatsFilter = props => (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
</Filter>
);
export const UserMediaStatsList = props => {
return (
<List
{...props}
actions={<ListActions />}
filters={<UserMediaStatsFilter />}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick={(id, basePath, record) => "/users/" + id + "/media"}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
};

View File

@ -1,79 +0,0 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const { sort, resource, displayedFilters, filterValues, showFilter, total } =
useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = props => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid
rowClick={(id, resource, record) => "/users/" + id + "/media"}
bulkActionButtons={false}
>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
const resource = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
export default resource;

View File

@ -1,23 +1,26 @@
import React, { cloneElement } from "react";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import React, { cloneElement, Fragment } from "react";
import Avatar from "@material-ui/core/Avatar";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import ContactMailIcon from "@material-ui/icons/ContactMail";
import DevicesIcon from "@material-ui/icons/Devices";
import GetAppIcon from "@material-ui/icons/GetApp";
import NotificationsIcon from "@material-ui/icons/Notifications";
import PermMediaIcon from "@material-ui/icons/PermMedia";
import PersonPinIcon from "@material-ui/icons/PersonPin";
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
import ViewListIcon from "@material-ui/icons/ViewList";
import {
ArrayInput,
ArrayField,
Button,
CreateButton,
Datagrid,
DateField,
Create,
Edit,
ExportButton,
List,
Filter,
Toolbar,
SimpleForm,
SimpleFormIterator,
@ -28,52 +31,48 @@ import {
PasswordInput,
TextField,
TextInput,
SearchInput,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectField,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
maxLength,
regex,
required,
useRecordContext,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
sanitizeListRestProps,
NumberField,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "./AvatarField";
import SaveQrButton from "./SaveQrButton";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices";
import { ProtectMediaButton, QuarantineMediaButton } from "./media";
import { makeStyles } from "@material-ui/core/styles";
import { Link } from "react-router-dom";
const choices_medium = [
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
];
const choices_type = [
{ id: "bot", name: "bot" },
{ id: "support", name: "support" },
];
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
const redirect = () => {
return {
pathname: "/importcsv",
};
};
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
large: {
height: "120px",
width: "120px",
float: "right",
},
});
const UserListActions = ({
sort,
currentSort,
className,
resource,
filters,
@ -82,6 +81,7 @@ const UserListActions = ({
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
basePath,
selectedIds,
onUnselectItems,
showFilter,
@ -99,18 +99,18 @@ const UserListActions = ({
filterValues,
context: "button",
})}
<CreateButton />
<CreateButton basePath={basePath} />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
sort={currentSort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
{/* Add your custom actions */}
<Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
<Button component={Link} to={redirect} label="CSV Import">
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</Button>
</TopToolbar>
);
@ -121,72 +121,107 @@ UserListActions.defaultProps = {
onUnselectItems: () => null,
};
const UserPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const UserPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
<BooleanInput
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>,
];
const UserFilter = props => (
<Filter {...props}>
<SearchInput source="name" alwaysOn />
<BooleanInput source="guests" alwaysOn />
<BooleanInput
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>
</Filter>
);
const UserBulkActionButtons = () => (
<>
<ServerNoticeBulkButton />
const UserBulkActionButtons = props => (
<Fragment>
<ServerNoticeBulkButton {...props} />
<BulkDeleteButton
{...props}
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
undoable={false}
/>
</>
</Fragment>
);
export const UserList = props => (
<List
{...props}
filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}>
<AvatarField
source="avatar_src"
sx={{ height: "40px", width: "40px" }}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<BooleanField source="deactivated" />
<DateField
source="creation_ts"
label="resources.users.fields.creation_ts_ms"
showTime
options={date_format}
/>
</Datagrid>
</List>
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
export const UserList = props => {
const classes = useStyles();
return (
<List
{...props}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit">
<AvatarField
source="avatar_src"
className={classes.small}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<SelectField
source="user_type"
choices={[
{ id: null, name: "resources.users.type.default" },
{ id: "free", name: "resources.users.type.free" },
{ id: "limited", name: "resources.users.type.limited" },
]}
/>
<BooleanField source="deactivated" />
</Datagrid>
</List>
);
};
// redirect to the related Author show page
const redirectToPdf = (basePath, id, data) => {
return {
pathname: "/showpdf",
state: {
id: data.id,
displayname: data.displayname,
password: data.password,
},
};
};
const UserCreateToolbar = props => (
<Toolbar {...props}>
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirectToPdf}
submitOnEnter={true}
/>
<SaveButton
label="synapseadmin.action.save_only"
redirect="list"
submitOnEnter={false}
variant="text"
/>
</Toolbar>
);
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
// maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length
// localStorage.getItem("home_server").length is not valid here
const validateUser = [
required(),
maxLength(253),
regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id"),
];
const validateAddress = [required(), maxLength(255)];
const validateUser = regex(
/^@[a-z0-9._=\-/]+:.*/,
"synapseadmin.users.invalid_user_id"
);
export function generateRandomUser() {
const homeserver = localStorage.getItem("home_server");
@ -229,77 +264,65 @@ export function generateRandomUser() {
};
}
const UserEditToolbar = props => (
<Toolbar {...props}>
<SaveButton disabled={props.pristine} />
</Toolbar>
);
const UserEditActions = ({ data }) => {
const UserEditToolbar = props => {
const translate = useTranslate();
var userStatus = "";
if (data) {
userStatus = data.deactivated;
}
return (
<TopToolbar>
{!userStatus && <ServerNoticeButton record={data} />}
<Toolbar {...props}>
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirect}
submitOnEnter={true}
/>
<SaveButton
label="synapseadmin.action.save_only"
redirect="list"
submitOnEnter={false}
variant="text"
/>
<DeleteButton
record={data}
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
/>
</TopToolbar>
<ServerNoticeButton />
</Toolbar>
);
};
export const UserCreate = props => (
<Create {...props}>
<SimpleForm>
<Create record={generateRandomUser()} {...props}>
<SimpleForm toolbar={<UserCreateToolbar />}>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput
source="password"
autoComplete="new-password"
validate={maxLength(512)}
/>
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
<BooleanInput source="admin" />
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
choices={[
{ id: null, name: "resources.users.type.default" },
{ id: "free", name: "resources.users.type.free" },
{ id: "limited", name: "resources.users.type.limited" },
]}
/>
<BooleanInput source="admin" />
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SimpleFormIterator>
<SelectInput
source="medium"
choices={choices_medium}
validate={required()}
/>
<TextInput source="address" validate={validateAddress} />
</SimpleFormIterator>
</ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
source="external_id"
label="resources.users.fields.id"
validate={required()}
choices={[
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
]}
/>
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
const UserTitle = () => {
const record = useRecordContext();
const UserTitle = ({ record }) => {
const translate = useTranslate();
return (
<span>
@ -312,9 +335,10 @@ const UserTitle = () => {
};
export const UserEdit = props => {
const classes = useStyles();
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<Edit {...props} title={<UserTitle />}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab
label={translate("resources.users.name", { smart_count: 1 })}
@ -323,27 +347,37 @@ export const UserEdit = props => {
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "120px", width: "120px", float: "right" }}
className={classes.large}
/>
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput
source="password"
autoComplete="new-password"
helperText="resources.users.helper.password"
/>
<PasswordInput source="password" autoComplete="new-password" />
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
choices={[
{ id: null, name: "resources.users.type.default" },
{ id: "free", name: "resources.users.type.free" },
{ id: "limited", name: "resources.users.type.limited" },
]}
emptyText="resources.users.type.default"
/>
<BooleanInput source="admin" />
<BooleanInput
source="deactivated"
helperText="resources.users.helper.deactivate"
/>
<DateField source="creation_ts_ms" showTime options={date_format} />
<DateField
source="creation_ts_ms"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
/>
<TextField source="consent_version" />
</FormTab>
@ -353,8 +387,14 @@ export const UserEdit = props => {
path="threepid"
>
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} />
<SimpleFormIterator>
<SelectInput
source="medium"
choices={[
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
]}
/>
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
@ -365,16 +405,16 @@ export const UserEdit = props => {
icon={<AssignmentIndIcon />}
path="sso"
>
<ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
<ArrayField source="external_ids" label={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="auth_provider" sortable={false} />
<TextField
source="external_id"
label="resources.users.fields.id"
validate={required()}
sortable={false}
/>
</SimpleFormIterator>
</ArrayInput>
</Datagrid>
</ArrayField>
</FormTab>
<FormTab
@ -394,7 +434,14 @@ export const UserEdit = props => {
<DateField
source="last_seen_ts"
showTime
options={date_format}
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<DeviceRemoveButton />
@ -417,12 +464,19 @@ export const UserEdit = props => {
source="devices[].sessions[0].connections"
label="resources.connections.name"
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="ip" sortable={false} />
<DateField
source="last_seen"
showTime
options={date_format}
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<TextField
@ -449,11 +503,29 @@ export const UserEdit = props => {
sort={{ field: "created_ts", order: "DESC" }}
>
<Datagrid style={{ width: "100%" }}>
<DateField source="created_ts" showTime options={date_format} />
<DateField
source="created_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
/>
<DateField
source="last_access_ts"
showTime
options={date_format}
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
/>
<TextField source="media_id" />
<NumberField source="media_length" />
@ -479,8 +551,7 @@ export const UserEdit = props => {
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/rooms/" + id + "/show"}
bulkActionButtons={false}
rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}
>
<TextField
source="id"
@ -510,7 +581,7 @@ export const UserEdit = props => {
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
@ -526,13 +597,3 @@ export const UserEdit = props => {
</Edit>
);
};
const resource = {
name: "users",
icon: UserIcon,
list: UserList,
edit: UserEdit,
create: UserCreate,
};
export default resource;

View File

@ -7,18 +7,30 @@ const de = {
base_url: "Heimserver URL",
welcome: "Willkommen bei Synapse-admin",
server_version: "Synapse Version",
supports_specs: "unterstützt Matrix-Specs",
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
url_error: "Keine gültige Matrix Server URL",
sso_sign_in: "Anmeldung mit SSO",
},
action: {
save_and_show: "Speichern und QR Code erzeugen",
save_only: "Nur speichern",
download_pdf: "PDF speichern",
},
users: {
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
invalid_user_id:
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
tabs: { sso: "SSO" },
},
rooms: {
details: "Raumdetails",
room_name: "Raumname",
make_public: "Öffentlicher Raum",
encrypt: "Verschlüsselter Raum",
room_name_required: "Muss angegeben werden",
alias_required_if_public: "Muss für öffentliche Räume angegeben werden.",
alias: "Alias",
alias_too_long:
"Darf zusammen mit der Domain des Homeservers 255 bytes nicht überschreiten",
tabs: {
basic: "Allgemein",
members: "Mitglieder",
@ -93,11 +105,14 @@ const de = {
with_error:
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
simulated_only: "Import-Vorgang war nur simuliert",
for_print:
"%{smart_count} Eintrag zum Drucken verfügbar |||| %{smart_count} Einträge zum Drucken verfügbar",
},
},
},
resources: {
users: {
backtolist: "Zurück zur Liste",
name: "Benutzer",
email: "E-Mail",
msisdn: "Telefon",
@ -122,11 +137,20 @@ const de = {
creation_ts_ms: "Zeitpunkt der Erstellung",
consent_version: "Zugestimmte Geschäftsbedingungen",
auth_provider: "Provider",
user_type: "Benutzertyp",
user_type: "Kontotyp",
// Devices:
device_id: "Geräte-ID",
display_name: "Gerätename",
last_seen_ts: "Zeitstempel",
last_seen_ip: "IP-Adresse",
role: "Rolle",
},
type: {
default: "Standard",
free: "Basic",
limited: "Eingeschränkt",
},
helper: {
password:
"Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate:
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
@ -134,6 +158,11 @@ const de = {
action: {
erase: "Lösche Benutzerdaten",
},
roles: {
user: "Nutzer",
mod: "Moderator",
admin: "Administrator",
},
},
rooms: {
name: "Raum |||| Räume",
@ -142,6 +171,8 @@ const de = {
name: "Name",
canonical_alias: "Alias",
joined_members: "Mitglieder",
invite_members: "Mitglieder einladen",
invitees: "Einladungen",
joined_local_members: "Lokale Mitglieder",
joined_local_devices: "Lokale Endgeräte",
state_events: "Zustandsereignisse / Komplexität",
@ -189,7 +220,7 @@ const de = {
},
},
reports: {
name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse",
name: "Ereignisbericht |||| Ereignisberichte",
fields: {
id: "ID",
received_ts: "Meldezeit",
@ -211,13 +242,6 @@ const de = {
},
},
},
action: {
erase: {
title: "Gemeldetes Event löschen",
content:
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
},
},
},
connections: {
name: "Verbindungen",
@ -363,31 +387,6 @@ const de = {
send_failure: "Beim Entfernen ist ein Fehler aufgetreten.",
},
},
destinations: {
name: "Föderation",
fields: {
destination: "Ziel",
failure_ts: "Fehlerzeitpunkt",
retry_last_ts: "Letzter Wiederholungsversuch",
retry_interval: "Wiederholungsintervall",
last_successful_stream_ordering: "letzte erfogreicher Stream",
stream_ordering: "Stream",
},
action: { reconnect: "Neu verbinden" },
},
registration_tokens: {
name: "Registrierungstoken",
fields: {
token: "Token",
valid: "Gültige Token",
uses_allowed: "Anzahl",
pending: "Ausstehend",
completed: "Abgeschlossen",
expiry_time: "Ablaufzeit",
length: "Länge",
},
helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." },
},
},
ra: {
...germanMessages.ra,
@ -408,7 +407,7 @@ const de = {
},
},
notification: {
...germanMessages.ra.notification,
...germanMessages.ra.notifiaction,
logged_out: "Abgemeldet",
},
page: {

View File

@ -7,17 +7,30 @@ const en = {
base_url: "Homeserver URL",
welcome: "Welcome to Synapse-admin",
server_version: "Synapse version",
supports_specs: "supports Matrix specs",
username_error: "Please enter fully qualified user ID: '@user:domain'",
protocol_error: "URL has to start with 'http://' or 'https://'",
url_error: "Not a valid Matrix server URL",
sso_sign_in: "Sign in with SSO",
},
action: {
save_and_show: "Create QR code",
save_only: "Save",
download_pdf: "Download PDF",
},
users: {
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
invalid_user_id:
"Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver",
tabs: { sso: "SSO" },
},
rooms: {
details: "Room Details",
room_name: "Room Name",
make_public: "Make room public",
encrypt: "Encrypt room",
room_name_required: "Must be provided",
alias_required_if_public: "Must be provided for a public room",
alias: "Alias",
alias_too_long:
"Must not exceed 255 bytes including the domain of the homeserver.",
tabs: {
basic: "Basic",
members: "Members",
@ -92,11 +105,14 @@ const en = {
with_error:
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
simulated_only: "Run was only simulated",
for_print:
"%{smart_count} entry available for printing |||| %{smart_count} entries available for printing",
},
},
},
resources: {
users: {
backtolist: "Back to list",
name: "User |||| Users",
email: "Email",
msisdn: "Phone",
@ -121,16 +137,30 @@ const en = {
creation_ts_ms: "Creation timestamp",
consent_version: "Consent version",
auth_provider: "Provider",
user_type: "User type",
// Devices:
device_id: "Device-ID",
display_name: "Device name",
last_seen_ts: "Timestamp",
last_seen_ip: "IP address",
role: "Role",
},
type: {
default: "Standard",
free: "Basic",
limited: "Limited",
},
helper: {
password: "Changing password will log user out of all sessions.",
deactivate: "You must provide a password to re-activate an account.",
erase: "Mark the user as GDPR-erased",
},
action: {
erase: "Erase user data",
},
roles: {
user: "User",
mod: "Moderator",
admin: "Administrator",
},
},
rooms: {
name: "Room |||| Rooms",
@ -139,6 +169,8 @@ const en = {
name: "Name",
canonical_alias: "Alias",
joined_members: "Members",
invite_members: "Invite Members",
invitees: "Invitations",
joined_local_members: "Local members",
joined_local_devices: "Local devices",
state_events: "State events / Complexity",
@ -177,12 +209,10 @@ const en = {
},
unencrypted: "Unencrypted",
},
action: {
erase: {
title: "Delete room",
content:
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
},
erase: {
title: "Delete room",
content:
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
},
},
reports: {
@ -198,7 +228,7 @@ const en = {
event_json: {
origin: "origin server",
origin_server_ts: "time of send",
type: "event type",
type: "event typ",
content: {
msgtype: "content type",
body: "content",
@ -208,13 +238,6 @@ const en = {
},
},
},
action: {
erase: {
title: "Delete reported event",
content:
"Are you sure you want to delete the reported event? This cannot be undone.",
},
},
},
connections: {
name: "Connections",
@ -353,38 +376,13 @@ const en = {
title:
"Delete room from directory |||| Delete %{smart_count} rooms from directory",
content:
"Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory?",
"Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory",
erase: "Delete from room directory",
create: "Publish in room directory",
send_success: "Room successfully published.",
send_failure: "An error has occurred.",
},
},
destinations: {
name: "Federation",
fields: {
destination: "Destination",
failure_ts: "Failure timestamp",
retry_last_ts: "Last retry timestamp",
retry_interval: "Retry interval",
last_successful_stream_ordering: "Last successful stream",
stream_ordering: "Stream",
},
action: { reconnect: "Reconnect" },
},
},
registration_tokens: {
name: "Registration tokens",
fields: {
token: "Token",
valid: "Valid token",
uses_allowed: "Uses allowed",
pending: "Pending",
completed: "Completed",
expiry_time: "Expiry time",
length: "Length",
},
helper: { length: "Length of the token if no token is given." },
},
};
export default en;

View File

@ -1,382 +0,0 @@
import farsiMessages from "ra-language-farsi";
const fa = {
...farsiMessages,
synapseadmin: {
auth: {
base_url: "آدرس سرور",
welcome: "به پنل مدیریت سیناپس خوش آمدید!",
server_version: "نسخه",
username_error: "لطفاً شناسه کاربر را وارد کنید: '@user:domain'",
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
url_error: "آدرس وارد شده یک سرور معتبر نیست",
sso_sign_in: "با SSO وارد شوید",
},
users: {
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
basic: "اصلی",
members: "اعضا",
detail: "جزئیات",
permission: "مجوزها",
},
},
reports: { tabs: { basic: "اصلی", detail: "جزئیات" } },
},
import_users: {
error: {
at_entry: "در هنگام ورود %{entry}: %{message}",
error: "Error",
required_field: "فیلد الزامی '%{field}' وجود ندارد",
invalid_value:
"خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد",
unreasonably_big:
"از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
already_in_progress: "یک بارگذاری از قبل در حال انجام است",
id_exits: "شناسه %{id} موجود است",
},
title: "کاربران را از طریق فایل CSV وارد کنید",
goToPdf: "رفتن به PDF",
cards: {
importstats: {
header: "وارد کردن کاربران",
users_total:
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
},
conflicts: {
header: "استراتژی متغارض",
mode: {
stop: "توقف",
skip: "نمایش خطا و رد شدن",
},
},
ids: {
header: "شناسنامه ها",
all_ids_present: "شناسه های موجود در هر ورودی",
count_ids_present:
"%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
mode: {
ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن",
update: "سوابق موجود را به روز کنید",
},
},
passwords: {
header: "رمز عبور",
all_passwords_present: "رمزهای عبور موجود در هر ورودی",
count_passwords_present:
"%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
use_passwords: "از پسوردهای CSV استفاده کنید",
},
upload: {
header: "Input CSV file",
explanation:
"در اینجا می توانید فایلی را با مقادیر جدا شده با کاما بارگذاری کنید که برای ایجاد یا به روز رسانی کاربران پردازش می شود. فایل باید شامل فیلدهای 'id' و 'displayname' باشد. می توانید یک فایل نمونه را از اینجا دانلود و تطبیق دهید: ",
},
startImport: {
simulate_only: "فقط شبیه سازی",
run_import: "بارگذاری",
},
results: {
header: "بارگذاری نتایج",
total: "%{smart_count} ورودی در کل |||| %{smart_count} ورودی ها در کل",
successful: "%{smart_count} ورودی ها با موفقیت وارد شدند",
skipped: "%{smart_count} ورودی ها نادیده گرفته شدند",
download_skipped: "دانلود رکوردهای نادیده گرفته شده",
with_error:
"%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
simulated_only: "اجرا فقط شبیه سازی شد",
},
},
},
resources: {
users: {
name: "کاربر |||| کاربران",
email: "ایمیل",
msisdn: "شماره تلفن",
threepid: "ایمیل / شماره تلفن",
fields: {
avatar: "آواتار",
id: "شناسه کاربر",
name: "نام",
is_guest: "مهمان",
admin: "مدیر سرور",
deactivated: "غیرفعال",
guests: "نمایش مهمانان",
show_deactivated: "نمایش کاربران غیرفعال شده",
user_id: "جستجوی کاربر",
displayname: "نام نمایشی",
password: "رمز عبور",
avatar_url: "آواتار سرور",
avatar_src: "آواتار",
medium: "متوسط",
threepids: "سرویس احراز هویت",
address: "آدرس",
creation_ts_ms: "ساخته شده در",
consent_version: "Consent نسخه",
auth_provider: "ارائه دهنده",
user_type: "نوع کاربر",
},
helper: {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
},
action: {
erase: "پاک کردن اطلاعات کاربر",
},
},
rooms: {
name: "اتاق |||| اتاق ها",
fields: {
room_id: "شناسه اتاق",
name: "نام",
canonical_alias: "نام مستعار",
joined_members: "اعضا",
joined_local_members: "اعضای محلی",
joined_local_devices: "دستگاه های محلی",
state_events: "رویدادهای حالت / پیچیدگی",
version: "نسخه",
is_encrypted: "رمزگذاری شده است",
encryption: "رمزگذاری",
federatable: "Federatable",
public: "قابل مشاهده در فهرست اتاق",
creator: "سازنده",
join_rules: "به قوانین بپیوندید",
guest_access: "دسترسی مهمان",
history_visibility: "مشاهده تاریخچه",
topic: "موضوع",
avatar: "آواتار",
},
helper: {
forward_extremities:
"اندام های رو به جلو، رویدادهای برگ در انتهای نمودار غیر چرخه ای جهت دار (DAG) در یک اتاق هستند، رویدادهایی که فرزند ندارند. هر چه تعداد بیشتری در یک اتاق وجود داشته باشد، وضوح حالت بیشتری را که سیناپس باید انجام دهد (نکته: این یک عملیات گران است). در حالی که Synapse کدی برای جلوگیری از وجود تعداد زیادی از این موارد در یک زمان در اتاق دارد، گاهی اوقات باگ‌ها می‌توانند دوباره ظاهر شوند. اگر اتاقی بیش از 10 انتهای رو به جلو دارد، بهتر است بررسی کنید که کدام اتاق مقصر است و احتمالاً آنها را با استفاده از جستارهای SQL ذکر شده در آن حذف کنید. #1760.",
},
enums: {
join_rules: {
public: "عمومی",
knock: "در زدن",
invite: "دعوت کردن",
private: "خصوصی",
},
guest_access: {
can_join: "مهمانان می توانند ملحق شوند",
forbidden: "مهمانان نمی توانند ملحق شوند",
},
history_visibility: {
invited: "از آنجایی که دعوت شده است",
joined: "از زمانی که پیوست",
shared: "از آنجایی که به اشتراک گذاشته شده است",
world_readable: "هر کسی",
},
unencrypted: "رمزگذاری نشده",
},
action: {
erase: {
title: "حذف اتاق",
content:
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
},
},
},
reports: {
name: "رویداد گزارش شده |||| رویدادهای گزارش شده",
fields: {
id: "شناسه",
received_ts: "زمان گزارش",
user_id: "گوینده",
name: "نام اتاق",
score: "نمره",
reason: "دلیل",
event_id: "شناسه رویداد",
event_json: {
origin: "سرور مبدا",
origin_server_ts: "زمان ارسال",
type: "نوع رویداد",
content: {
msgtype: "نوع محتوا",
body: "محتوا",
format: "قالب",
formatted_body: "محتوای قالب بندی شده",
algorithm: "الگوریتم",
},
},
},
},
connections: {
name: "اتصالات",
fields: {
last_seen: "تاریخ",
ip: "آدرس آی پی",
user_agent: "نماینده کاربر",
},
},
devices: {
name: "دستگاه |||| دستگاه ها",
fields: {
device_id: "شناسه دستگاه",
display_name: "نام دستگاه",
last_seen_ts: "مهر زمان",
last_seen_ip: "آدرس آی پی",
},
action: {
erase: {
title: "حذف کردن %{id}",
content:
'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
success: "دستگاه با موفقیت حذف شد.",
failure: "خطایی رخ داده است.",
},
},
},
users_media: {
name: "رسانه ها",
fields: {
media_id: "شناسه رسانه",
media_length: "اندازه فایل (به بایت)",
media_type: "نوع",
upload_name: "نام فایل",
quarantined_by: "قرنطینه شده توسط",
safe_from_quarantine: "امان از قرنطینه",
created_ts: "ایجاد شده",
last_access_ts: "آخرین دسترسی",
},
},
delete_media: {
name: "رسانه ها",
fields: {
before_ts: "آخرین دسترسی قبل",
size_gt: "بزرگتر از آن (به بایت)",
keep_profiles: "تصاویر پروفایل را نگه دارید",
},
action: {
send: "حذف رسانه ها",
send_success: "درخواست با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانه‌هایی که در مخازن رسانه خارجی آپلود شده‌اند تأثیری نخواهد گذاشت.",
},
},
protect_media: {
action: {
create: "محافظت نشده، حفاظت ایجاد کنید",
delete: "محافظت شده، حفاظت را بردارید",
none: "در قرنطینه",
send_success: "وضعیت حفاظت با موفقیت تغییر کرد.",
send_failure: "خطایی رخ داده است.",
},
},
quarantine_media: {
action: {
name: "قرنطینه",
create: "به قرنطینه اضافه کنید",
delete: "در قرنطینه، غیر قرنطینه",
none: "از قرنطینه محافظت می شود",
send_success: "وضعیت قرنطینه با موفقیت تغییر کرد.",
send_failure: "خطایی رخ داده است.",
},
},
pushers: {
name: "هل دهنده |||| هل دهنده ها",
fields: {
app: "برنامه",
app_display_name: "نام نمایش برنامه",
app_id: "شناسه برنامه",
device_display_name: "نام نمایشی برنامه",
kind: "نوع",
lang: "زبان",
profile_tag: "برچسب پروفایل",
pushkey: "کلید",
data: { url: "URL" },
},
},
servernotices: {
name: "اطلاعیه های سرور",
send: "ارسال اعلانات سرور",
fields: {
body: "پیام",
},
action: {
send: "ارسال یادداشت",
send_success: "اعلان سرور با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "اعلان سرور را برای کاربران انتخاب شده ارسال می کند. ویژگی 'اعلامیه های سرور' باید در سرور فعال شود.",
},
},
user_media_statistics: {
name: "رسانه کاربران",
fields: {
media_count: "شمارش رسانه ها",
media_length: "طول رسانه",
},
},
forward_extremities: {
name: "Forward Extremities",
fields: {
id: "شناسه رویداد",
received_ts: "مهر زمان",
depth: "عمق",
state_group: "گروه دولتی",
},
},
room_state: {
name: "رویدادهای وضعیت",
fields: {
type: "نوع",
content: "محتوا",
origin_server_ts: "زمان ارسال",
sender: "فرستنده",
},
},
room_directory: {
name: "راهنمای اتاق",
fields: {
world_readable: "کاربران مهمان می توانند بدون عضویت مشاهده کنند",
guest_can_join: "کاربران مهمان ممکن است ملحق شوند",
},
action: {
title:
"اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
content:
"آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟",
erase: "حذف از فهرست اتاق",
create: "انتشار در راهنما اتاق",
send_success: "اتاق با موفقیت منتشر شد.",
send_failure: "خطایی رخ داده است.",
},
},
destinations: {
name: "سرور های مرتبط",
fields: {
destination: "آدرس",
failure_ts: "زمان شکست",
retry_last_ts: "آخرین زمان اتصال",
retry_interval: "بازه امتحان مجدد",
last_successful_stream_ordering: "آخرین جریان موفق",
stream_ordering: "جریان",
},
action: { reconnect: "دوباره وصل شوید" },
},
},
registration_tokens: {
name: "توکن های ثبت نام",
fields: {
token: "توکن",
valid: "توکن معتبر",
uses_allowed: "موارد استفاده مجاز",
pending: "انتظار",
completed: "تکمیل شد",
expiry_time: "زمان انقضا",
length: "طول",
},
helper: { length: "طول توکن در صورت عدم ارائه توکن." },
},
};
export default fa;

View File

@ -1,377 +0,0 @@
import frenchMessages from "ra-language-french";
const fr = {
...frenchMessages,
synapseadmin: {
auth: {
base_url: "URL du serveur daccueil",
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 lauthentification unique",
},
users: {
invalid_user_id:
"Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
tabs: { sso: "Authentification unique" },
},
rooms: {
tabs: {
basic: "Informations de base",
members: "Membres",
detail: "Détails",
permission: "Permissions",
},
},
reports: { tabs: { basic: "Informations de base", detail: "Détails" } },
},
import_users: {
error: {
at_entry: "Pour l'entrée %{entry} : %{message}",
error: "Erreur",
required_field: "Le champ requis « %{field} » est manquant",
invalid_value:
"Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »",
unreasonably_big:
"Refus de charger un fichier trop volumineux de %{size} mégaoctets",
already_in_progress: "Un import est déjà en cours",
id_exits: "L'identifiant %{id} déjà présent",
},
title: "Importer des utilisateurs à partir d'un fichier CSV",
goToPdf: "Voir le PDF",
cards: {
importstats: {
header: "Importer des utilisateurs",
users_total:
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
admin_count:
"%{smart_count} administrateur |||| %{smart_count} administrateurs",
},
conflicts: {
header: "Stratégie de résolution des conflits",
mode: {
stop: "S'arrêter en cas de conflit",
skip: "Afficher l'erreur et ignorer le conflit",
},
},
ids: {
header: "Identifiants",
all_ids_present: "Identifiants présents pour chaque entrée",
count_ids_present:
"%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
mode: {
ignore:
"Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
update: "Mettre à jour les enregistrements existants",
},
},
passwords: {
header: "Mots de passe",
all_passwords_present: "Mots de passe présents pour chaque entrée",
count_passwords_present:
"%{smart_count} entrée avec mot de passe |||| %{smart_count} entrées avec mot de passe",
use_passwords: "Utiliser les mots de passe provenant du fichier CSV",
},
upload: {
header: "Fichier CSV en entrée",
explanation:
"Vous pouvez télécharger ici un fichier contenant des valeurs séparées par des virgules qui sera traité pour créer ou mettre à jour des utilisateurs. Le fichier doit inclure les champs « id » et « displayname ». Vous pouvez télécharger et adapter un fichier d'exemple ici : ",
},
startImport: {
simulate_only: "Simuler",
run_import: "Importer",
},
results: {
header: "Résultats de l'import",
total:
"%{smart_count} entrée au total |||| %{smart_count} entrées au total",
successful: "%{smart_count} entrées importées avec succès",
skipped: "%{smart_count} entrées ignorées",
download_skipped: "Télécharger les entrées ignorées",
with_error:
"%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
simulated_only: "L'import était simulé",
},
},
},
resources: {
users: {
name: "Utilisateur |||| Utilisateurs",
email: "Adresse électronique",
msisdn: "Numéro de téléphone",
threepid: "Adresse électronique / Numéro de téléphone",
fields: {
avatar: "Avatar",
id: "Identifiant",
name: "Nom",
is_guest: "Visiteur",
admin: "Administrateur du serveur",
deactivated: "Désactivé",
guests: "Afficher les visiteurs",
show_deactivated: "Afficher les utilisateurs désactivés",
user_id: "Rechercher un utilisateur",
displayname: "Nom d'affichage",
password: "Mot de passe",
avatar_url: "URL de l'avatar",
avatar_src: "Avatar",
medium: "Type",
threepids: "Identifiants tiers",
address: "Adresse",
creation_ts_ms: "Date de création",
consent_version: "Version du consentement",
auth_provider: "Fournisseur d'identité",
},
helper: {
deactivate:
"Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
},
action: {
erase: "Effacer les données de l'utilisateur",
},
},
rooms: {
name: "Salon |||| Salons",
fields: {
room_id: "Identifiant du salon",
name: "Nom",
canonical_alias: "Alias",
joined_members: "Membres",
joined_local_members: "Membres locaux",
joined_local_devices: "Appareils locaux",
state_events: "Événements d'État / Complexité",
version: "Version",
is_encrypted: "Chiffré",
encryption: "Chiffrement",
federatable: "Fédérable",
public: "Visible dans le répertoire des salons",
creator: "Créateur",
join_rules: "Règles d'adhésion",
guest_access: "Accès des visiteurs",
history_visibility: "Visibilité de l'historique",
topic: "Sujet",
avatar: "Avatar",
},
helper: {
forward_extremities:
"Les extrémités avant sont les événements feuilles à la fin d'un graphe orienté acyclique (DAG) dans un salon, c'est-à-dire les événements qui n'ont pas de descendants. Plus il y en a dans un salon, plus la résolution d'état que Synapse doit effectuer est importante (indice : c'est une opération coûteuse). Bien que Synapse dispose d'un algorithme pour éviter qu'un trop grand nombre de ces événements n'existent en même temps dans un salon, des bogues peuvent parfois les faire réapparaître. Si un salon présente plus de 10 extrémités avant, cela vaut la peine d'y prêter attention et éventuellement de les supprimer en utilisant les requêtes SQL mentionnées dans la discussion traitant du problème https://github.com/matrix-org/synapse/issues/1760.",
},
enums: {
join_rules: {
public: "Public",
knock: "Sur demande",
invite: "Sur invitation",
private: "Privé",
},
guest_access: {
can_join: "Les visiteurs peuvent rejoindre le salon",
forbidden: "Les visiteurs ne peuvent pas rejoindre le salon",
},
history_visibility: {
invited: "Depuis l'invitation",
joined: "Depuis l'adhésion",
shared: "Depuis le partage",
world_readable: "Tout le monde",
},
unencrypted: "Non chiffré",
},
action: {
erase: {
title: "Supprimer le salon",
content:
"Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !",
},
},
},
reports: {
name: "Événement signalé |||| Événements signalés",
fields: {
id: "Identifiant",
received_ts: "Date du rapport",
user_id: "Rapporteur",
name: "Nom du salon",
score: "Score",
reason: "Motif",
event_id: "Identifiant de l'événement",
event_json: {
origin: "Serveur d'origine",
origin_server_ts: "Date d'envoi",
type: "Type d'événement",
content: {
msgtype: "Type de contenu",
body: "Contenu",
format: "Format",
formatted_body: "Contenu mis en forme",
algorithm: "Algorithme",
},
},
},
},
connections: {
name: "Connexions",
fields: {
last_seen: "Date",
ip: "Adresse IP",
user_agent: "Agent utilisateur",
},
},
devices: {
name: "Appareil |||| Appareils",
fields: {
device_id: "Identifiant de l'appareil",
display_name: "Nom de l'appareil",
last_seen_ts: "Date",
last_seen_ip: "Adresse IP",
},
action: {
erase: {
title: "Suppression de %{id}",
content: "Voulez-vous vraiment supprimer l'appareil « %{name} » ?",
success: "Appareil supprimé avec succès",
failure: "Une erreur s'est produite",
},
},
},
users_media: {
name: "Media",
fields: {
media_id: "Identifiant du média",
media_length: "Taille du fichier (en octets)",
media_type: "Type",
upload_name: "Nom du fichier",
quarantined_by: "Mis en quarantaine par",
safe_from_quarantine: "Protection contre la mise en quarantaine",
created_ts: "Date de création",
last_access_ts: "Dernier accès",
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "Dernier accès avant",
size_gt: "Plus grand que (en octets)",
keep_profiles: "Conserver les images de profil",
},
action: {
send: "Supprimer le média",
send_success: "Requête envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
},
},
protect_media: {
action: {
create: "Protéger",
delete: "Révoquer la protection",
none: "En quarantaine",
send_success: "Le statut de protection a été modifié avec succès",
send_failure: "Une erreur s'est produite",
},
},
quarantine_media: {
action: {
name: "Quarantaine",
create: "Mettre en quarantaine",
delete: "Révoquer la mise en quarantaine",
none: "Protégé contre la mise en quarantaine",
send_success: "Le statut de la quarantaine a été modifié avec succès",
send_failure: "Une erreur s'est produite",
},
},
pushers: {
name: "Émetteur de notifications |||| Émetteurs de notifications",
fields: {
app: "Application",
app_display_name: "Nom d'affichage de l'application",
app_id: "Identifiant de l'application",
device_display_name: "Nom d'affichage de l'appareil",
kind: "Type",
lang: "Langue",
profile_tag: "Profil",
pushkey: "Identifiant de l'émetteur",
data: { url: "URL" },
},
},
servernotices: {
name: "Annonces du serveur",
send: "Envoyer des « Annonces du serveur »",
fields: {
body: "Message",
},
action: {
send: "Envoyer une annonce",
send_success: "Annonce envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Envoie une annonce au nom du serveur aux utilisateurs sélectionnés. La fonction « Annonces du serveur » doit être activée sur le serveur.",
},
},
user_media_statistics: {
name: "Médias des utilisateurs",
fields: {
media_count: "Nombre de médias",
media_length: "Taille des médias",
},
},
forward_extremities: {
name: "Extrémités avant",
fields: {
id: "Identifiant de l'événement",
received_ts: "Date de réception",
depth: "Profondeur",
state_group: "Groupe d'état",
},
},
room_state: {
name: "Événements d'état",
fields: {
type: "Type",
content: "Contenu",
origin_server_ts: "Date d'envoi",
sender: "Expéditeur",
},
},
room_directory: {
name: "Répertoire des salons",
fields: {
world_readable:
"Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
guest_can_join: "Les visiteurs peuvent rejoindre le salon",
},
action: {
title:
"Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
content:
"Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?",
erase: "Supprimer du répertoire des salons",
create: "Publier dans le répertoire des salons",
send_success: "Salon publié avec succès",
send_failure: "Une erreur s'est produite",
},
},
},
registration_tokens: {
name: "Jetons d'inscription",
fields: {
token: "Jeton",
valid: "Jeton valide",
uses_allowed: "Nombre d'inscription autorisées",
pending: "Nombre d'inscription en cours",
completed: "Nombre d'inscription accomplie",
expiry_time: "Date d'expiration",
length: "Longueur",
},
helper: {
length:
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
},
},
};
export default fr;

View File

@ -1,385 +0,0 @@
import italianMessages from "ra-language-italian";
const it = {
...italianMessages,
synapseadmin: {
auth: {
base_url: "URL dell'homeserver",
welcome: "Benvenuto in Synapse-admin",
server_version: "Versione di Synapse",
username_error:
"Per favore inserisci un ID utente completo: '@utente:dominio'",
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
url_error: "URL del server Matrix non valido",
sso_sign_in: "Accedi con SSO",
},
users: {
invalid_user_id: "ID utente non valido su questo homeserver.",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
basic: "Semplice",
members: "Membro",
detail: "Dettagli",
permission: "Permessi",
},
},
reports: { tabs: { basic: "Semplice", detail: "Dettagli" } },
},
import_users: {
error: {
at_entry: "Alla voce %{entry}: %{message}",
error: "Errore",
required_field: "Il campo '%{field}' non è presente",
invalid_value:
"Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'",
unreasonably_big:
"Impossibile caricare un file così grosso (%{size} megabyte)",
already_in_progress: "Un import è attualmente già in caricamento",
id_exits: "L'ID %{id} è già presente",
},
title: "Importa utenti tramite file CSV",
goToPdf: "Vai al PDF",
cards: {
importstats: {
header: "Importa utenti",
users_total:
"%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti",
admin_count:
"%{smart_count} amministratore |||| %{smart_count} amministratori",
},
conflicts: {
header: "Strategia di conflitto",
mode: {
stop: "Stoppa al conflitto",
skip: "Mostra l'errore e ignora il conflitto",
},
},
ids: {
header: "ID",
all_ids_present: "ID presenti in ogni voce",
count_ids_present:
"%{smart_count} voce con ID |||| %{smart_count} voci con ID",
mode: {
ignore: "Ignora gli ID nel file CSV e creane di nuovi",
update: "Aggiorna le voci esistenti",
},
},
passwords: {
header: "Passwords",
all_passwords_present: "Password presenti in ogni voce",
count_passwords_present:
"%{smart_count} voce con password |||| %{smart_count} voci con password",
use_passwords: "Usa le password dal file CSV",
},
upload: {
header: "Input file CSV",
explanation:
"Qui puoi caricare un file con valori separati da virgole che verrà poi utilizzato per creare o aggiornare gli utenti. Il file deve includere i campi 'id' and 'displayname'. Puoi scaricare un file di esempio per adattarlo: ",
},
startImport: {
simulate_only: "Solo simulazione",
run_import: "Importa",
},
results: {
header: "Importa i risultati",
total:
"%{smart_count} voce in totale |||| %{smart_count} voci in totale",
successful: "%{smart_count} voci importate con successo",
skipped: "%{smart_count} voci ignorate",
download_skipped: "Scarica le voci ignorate",
with_error:
"%{smart_count} voce con errori ||| %{smart_count} voci con errori",
simulated_only: "Il processo era stato solamente simulato",
},
},
},
resources: {
users: {
name: "Utente |||| Utenti",
email: "Email",
msisdn: "Telefono",
threepid: "Email / Telefono",
fields: {
avatar: "Avatar",
id: "ID utente",
name: "Nome",
is_guest: "Ospite",
admin: "Amministratore",
deactivated: "Disattivato",
guests: "Mostra gli ospiti",
show_deactivated: "Mostra gli utenti disattivati",
user_id: "Cerca utente",
displayname: "Nickname",
password: "Password",
avatar_url: "URL dell'avatar",
avatar_src: "Avatar",
medium: "Medium",
threepids: "3PID",
address: "Indirizzo",
creation_ts_ms: "Creazione del timestamp",
consent_version: "Versione minima richiesta",
auth_provider: "Provider",
user_type: "Tipo d'utente",
},
helper: {
password:
"Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
deactivate: "Devi fornire una password per riattivare l'account.",
erase: "Constrassegna l'utente come cancellato dal GDPR",
},
action: {
erase: "Cancella i dati dell'utente",
},
},
rooms: {
name: "Stanza |||| Stanze",
fields: {
room_id: "ID della stanza",
name: "Nome",
canonical_alias: "Alias",
joined_members: "Membri",
joined_local_members: "Membri locali",
joined_local_devices: "Dispositivi locali",
state_events: "Eventi di stato / Complessità",
version: "Versione",
is_encrypted: "Criptato",
encryption: "Crittografia",
federatable: "Federabile",
public: "Visibile nella cartella della stanza",
creator: "Creatore",
join_rules: "Regole per entrare",
guest_access: "Entra come ospite",
history_visibility: "Visibilità temporale",
topic: "Topic",
avatar: "Avatar",
},
helper: {
/* forward_extremities:
"Forward extremities are the leaf events at the end of a Directed acyclic graph (DAG) in a room, aka events that have no children. The more exist in a room, the more state resolution that Synapse needs to perform (hint: it's an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth checking which room is the culprit and potentially removing them using the SQL queries mentioned in #1760.", */
},
enums: {
join_rules: {
public: "Pubblica",
knock: "Bussa",
invite: "Invita",
private: "Privata",
},
guest_access: {
can_join: "Gli utenti ospiti possono entrare",
forbidden: "Gli utenti ospiti non possono entrare",
},
history_visibility: {
invited: "Dall'invito",
joined: "Dall'entrata",
shared: "Dalla condivisione",
world_readable: "Chiunque",
},
unencrypted: "Non criptata",
},
action: {
erase: {
title: "Cancella stanza",
content:
"Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!",
},
},
},
reports: {
name: "Evento segnalato |||| Eventi segnalati",
fields: {
id: "ID",
received_ts: "Orario del report",
user_id: "richiedente",
name: "nome della stanza",
score: "punteggio",
reason: "ragione",
event_id: "ID dell'evento",
event_json: {
origin: "server di origine",
origin_server_ts: "ora dell'invio",
type: "tipo di evento",
content: {
msgtype: "tipo di contenuto",
body: "contenuto",
format: "formato",
formatted_body: "contenuto formattato",
algorithm: "algoritmo",
},
},
},
},
connections: {
name: "Connessioni",
fields: {
last_seen: "Data",
ip: "Indirizzo IP",
user_agent: "agente utente",
},
},
devices: {
name: "Dispositivo |||| Dispositivi",
fields: {
device_id: "ID del dispositivo",
display_name: "Nome del dispositivo",
last_seen_ts: "Timestamp",
last_seen_ip: "Indirizzo IP",
},
action: {
erase: {
title: "Rimozione del dispositivo %{id}",
content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?',
success: "Dispositivo rimosso con successo.",
failure: "C'è stato un errore.",
},
},
},
users_media: {
name: "Media",
fields: {
media_id: "ID del media",
media_length: "Peso del file (in Byte)",
media_type: "Tipo",
upload_name: "Nome del file",
quarantined_by: "In quarantena da",
safe_from_quarantine: "Protetto dalla quarantena",
created_ts: "Creato",
last_access_ts: "Ultimo accesso",
},
},
delete_media: {
name: "Media",
fields: {
before_ts: "ultimo accesso effettuato prima",
size_gt: "Più grande di (in byte)",
keep_profiles: "Mantieni le immagini del profilo",
},
action: {
send: "Cancella media",
send_success: "Richiesta inviata con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
},
},
protect_media: {
action: {
create: "Non protetto, proteggi",
delete: "Protetto, rimuovi protezione",
none: "In quarantena",
send_success: "Stato della protezione cambiato con successo.",
send_failure: "C'è stato un errore.",
},
},
quarantine_media: {
action: {
name: "Quarantina",
create: "Aggiungi alla quarantena",
delete: "In quarantena, rimuovi dalla quarantena",
none: "Protetto dalla quarantena",
send_success: "Stato della quarantena cambiato con successo.",
send_failure: "C'è stato un errore.",
},
},
pushers: {
name: "Pusher |||| Pusher",
fields: {
app: "App",
app_display_name: "Nome dell'app",
app_id: "ID dell'app",
device_display_name: "Nome del dispositivo",
kind: "Tipo",
lang: "Lingua",
profile_tag: "Tag del profilo",
pushkey: "Pushkey",
data: { url: "URL" },
},
},
servernotices: {
name: "Avvisi del server",
send: "Invia avvisi",
fields: {
body: "Messaggio",
},
action: {
send: "Invia nota",
send_success: "Avviso inviato con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.',
},
},
user_media_statistics: {
name: "Media degli utenti",
fields: {
media_count: "Numero media",
media_length: "Lunghezza media",
},
},
forward_extremities: {
name: "Invia estremità",
fields: {
id: "Event ID",
received_ts: "Timestamp",
depth: "Profondità",
state_group: "State group",
},
},
room_state: {
name: "Eventi di stato",
fields: {
type: "Tipo",
content: "Contenuto",
origin_server_ts: "Ora dell'invio",
sender: "Mittente",
},
},
room_directory: {
name: "Elenco delle stanze",
fields: {
world_readable: "gli utenti ospite possono vedere senza entrare",
guest_can_join: "gli utenti ospite possono entrare",
},
action: {
title:
"Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
content:
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
erase: "Rimuovi dall'elenco",
create: "Crea",
send_success: "Stanza creata con successo.",
send_failure: "C'è stato un errore.",
},
},
destinations: {
name: "Federazione",
fields: {
destination: "Destinazione",
failure_ts: "Timestamp dell'errore",
retry_last_ts: "Tentativo ultimo timestamp",
retry_interval: "Intervallo dei tentativi",
last_successful_stream_ordering: "Ultimo flusso riuscito con successo",
stream_ordering: "Flusso",
},
action: { reconnect: "Riconnetti" },
},
},
registration_tokens: {
name: "Token di registrazione",
fields: {
token: "Token",
valid: "Token valido",
uses_allowed: "Usi permessi",
pending: "In attesa",
completed: "Completato",
expiry_time: "Data della scadenza",
length: "Lunghezza",
},
helper: { length: "Lunghezza del token se non viene dato alcun token." },
},
};
export default it;

View File

@ -1,390 +0,0 @@
import russianMessages from "ra-language-russian";
const ru = {
...russianMessages,
synapseadmin: {
auth: {
base_url: "Домашняя страница",
welcome: "Добро пожаловать в Synapse-admin",
server_version: "Версия Synapse",
supports_specs: "поддерживает спецификации Matrix",
username_error: "Введите полный идентификатор пользователя: '@user:domain'",
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
url_error: "Некорректный сервер Matrix",
sso_sign_in: "Присоединиться с помощью SSO",
},
users: {
invalid_user_id: "Локальная часть идентификатора пользователя Matrix без домашнего сервера.",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
basic: "Основное",
members: "Участники",
detail: "Подробности",
permission: "Права",
},
},
reports: { tabs: { basic: "Основное", detail: "Подробности" } },
},
import_users: {
error: {
at_entry: "При входе %{entry}: %{message}",
error: "Ошибка",
required_field: "Обязательное поле '%{field}' не представленно",
invalid_value:
"Недопустимое значение в строке %{row}. '%{field}' поле может быть только 'true' или 'false'",
unreasonably_big:
"Отказался загружать неоправданно большой файл %{size} Мбайт",
already_in_progress: "Импорт уже запущен",
id_exits: "Идентификатор %{id} уже существует",
},
title: "Импорт пользователей из CSV",
goToPdf: "В PDF",
cards: {
importstats: {
header: "Импорт пользователей",
users_total:
"%{smart_count} пользователь в CSV файл |||| %{smart_count} пользователей в CSV файл",
guest_count: "%{smart_count} гость |||| %{smart_count} гостей",
admin_count: "%{smart_count} администратор |||| %{smart_count} администраторов",
},
conflicts: {
header: "Решение конфликтов",
mode: {
stop: "Остановиться, если конфликт произошел",
skip: "Вывести ошибку и пропустить конфликт",
},
},
ids: {
header: "Идентификаторы",
all_ids_present: "Идентификаторы представлены для каждой записи",
count_ids_present:
"%{smart_count} запись с идентификатором |||| %{smart_count} записей с идентификатором",
mode: {
ignore: "Игнорировать идентификаторы в CSV и создавать новые",
update: "Обновлять существующие записи",
},
},
passwords: {
header: "Пароли",
all_passwords_present: "Пароли представлены для каждой записи",
count_passwords_present:
"%{smart_count} запись с паролем |||| %{smart_count} записей с паролем",
use_passwords: "Использовать пароли из CSV",
},
upload: {
header: "Загрузка CSV файла",
explanation:
"Здесь вы можете загрузить файл со значениями, разделенными запятыми, который будет обработан для создания или обновления пользователей. Файл должен содержать поля 'id' и 'displayname'. Вы можете загрузить пример здесь:",
},
startImport: {
simulate_only: "Не выполнять реальных действий",
run_import: "Импорт",
},
results: {
header: "Импорт результатов",
total:
"Всего %{smart_count} запись |||| Всего %{smart_count} записей",
successful: "%{smart_count} записей успешно импортировано",
skipped: "%{smart_count} записей пропущено",
download_skipped: "Загрузить пропущенные записи",
with_error:
"%{smart_count} запись с ошибками ||| %{smart_count} записей с ошибками",
simulated_only: "Результат не будет сохранен",
},
},
},
resources: {
users: {
name: "Пользователей |||| Пользователи",
email: "Почта",
msisdn: "Телефон",
threepid: "Почта / Телефон",
fields: {
avatar: "Аватар",
id: "Идентификатор пользователя",
name: "Имя",
is_guest: "Гость",
admin: "Администратор",
deactivated: "Деактивирован",
guests: "Показать гостей",
show_deactivated: "Показать деактивированных пользователей",
user_id: "Найти пользователя",
displayname: "Отображаемое имя",
password: "Пароль",
avatar_url: "Ссылка на аватар",
avatar_src: "Аватар",
medium: "Тип",
threepids: "Иной идентификатор",
address: "Адрес",
creation_ts_ms: "Время создания",
consent_version: "Версия соглашения",
auth_provider: "Поставщик",
user_type: "Тип пользователя",
},
helper: {
password: "Изменение пароля приведет к выходу пользователя из всех сеансов.",
deactivate: "Вы должны ввести пароль для повторной активации учетной записи.",
erase: "Пометить пользователя как удаленного в связи с защитой персональных данных",
},
action: {
erase: "Удаление пользовательских данных",
},
},
rooms: {
name: "Комнат |||| Комнаты",
fields: {
room_id: "Идентификатор комнаты",
name: "Название",
canonical_alias: "Псевдоним",
joined_members: "Участники",
joined_local_members: "Внутренние участники",
joined_local_devices: "Используемые устройства",
state_events: "События изменения состояния",
version: "Версия",
is_encrypted: "Зашифрованно",
encryption: "Шифрование",
federatable: "Федерация",
public: "Видимость в списке комнат",
creator: "Создатель",
join_rules: "Правила присоединения",
guest_access: "Гостевой доступ",
history_visibility: "Видимость истории",
topic: "Тема",
avatar: "Аватар",
},
helper: {
forward_extremities:
"Перенаправленные заключения, это такие события, которые не имеют потомков в рамках графа, отвечающего за их репрезентацию. Чем больше пользователей находится в комнате, тем больше операций требуется выполнить Synapse для разрешения коллиций, которые возникают при их проверке наступления событий (это дорогостоящая операция). Хотя в Synapse есть код, предотвращающий одновременное присутствие слишком большого количества таких объектов в комнате, ошибки иногда могут привести к их повторному появлению. Если в комнате содержится >10 перенаправленных заключений, имеет смысл выяснить, какая комната стала причиной их появления и, возможно, удалить их, используя SQL-запросы, упомянутые issue #1760.",
},
enums: {
join_rules: {
public: "Публичный",
knock: "По запросу",
invite: "По приглашению",
private: "Приватный",
},
guest_access: {
can_join: "Гости могут присоединиться",
forbidden: "Гости не могут присоединиться",
},
history_visibility: {
invited: "С момента приглашения",
joined: "С момента присоединения",
shared: "С момента разрешения",
world_readable: "Всегда",
},
unencrypted: "Не зашифрованно",
},
action: {
erase: {
title: "Удалить комнату",
content:
"Вы уверены, что хотите удалить комнату? Это невозможно отменить. Все сообщения и медиафайлы, находящиеся в общем доступе в комнате, будут удалены с сервера!",
},
},
},
reports: {
name: "Жалоб |||| Жалобы",
fields: {
id: "идентификатор",
received_ts: "время жалобы",
user_id: "коментатор",
name: "название комнаты",
score: "оценка",
reason: "причина",
event_id: "идентификатор события",
event_json: {
origin: "сервер",
origin_server_ts: "время отправки",
type: "тип события",
content: {
msgtype: "тип содержания",
body: "содержание",
format: "формат",
formatted_body: "форматированное содержание",
algorithm: "алгоритм",
},
},
},
action: {
erase: {
title: "Удалить жалобу",
content:
"Вы уверены, что хотите удалить жалобу? Это невозможно отменить.",
},
},
},
connections: {
name: "Подключения",
fields: {
last_seen: "Дата",
ip: "IP адрес",
user_agent: "User agent",
},
},
devices: {
name: "Устройств |||| Устройства",
fields: {
device_id: "Идентификатор устройства",
display_name: "Название устройства",
last_seen_ts: "Метка времени",
last_seen_ip: "IP адрес",
},
action: {
erase: {
title: "Удаление %{id}",
content: 'Вы уверены, что хотите удалить устройство "%{name}"?',
success: "Устройство успешно удалено.",
failure: "Произошла ошибка.",
},
},
},
users_media: {
name: "Медиа",
fields: {
media_id: "Идентификатор медиа",
media_length: "Размер файла (в байтах)",
media_type: "Тип",
upload_name: "Имя файла",
quarantined_by: "Отправлен в карантин",
safe_from_quarantine: "Защищен от карантина",
created_ts: "Создан",
last_access_ts: "Последнее обращение",
},
},
delete_media: {
name: "Медиа",
fields: {
before_ts: "последнее обращение",
size_gt: "Больше чем (в байтах)",
keep_profiles: "Сохранить аватары",
},
action: {
send: "Удалить медиафайлы",
send_success: "Запрос отправлен.",
send_failure: "Произошла ошибка.",
},
helper: {
send: "Этот метод удаляет медиафайлы с диска сервера. Это включает в себя любые миниатюры и копии загруженных файлов. Этот метод не повлияет на файлы, загруженные во внешние хранилища.",
},
},
protect_media: {
action: {
create: "Не защищено, установить защиту",
delete: "Защищено, удалить защиту",
none: "В карантине",
send_success: "Успешно изменен статус защиты.",
send_failure: "Произошла ошибка.",
},
},
quarantine_media: {
action: {
name: "Карантин",
create: "Добавить в карантин",
delete: "В карантине, вывести из карантина",
none: "Защищено от карантина",
send_success: "Успешно изменен статус карантина.",
send_failure: "Произошла ошибка.",
},
},
pushers: {
name: "Уведомление |||| Уведомления",
fields: {
app: "Приложение",
app_display_name: "Отображаемое имя приложения",
app_id: "Идентификатор приложения",
device_display_name: "Отображаемое имя устройства",
kind: "Тип",
lang: "Язык",
profile_tag: "Тег профиля",
pushkey: "Токен доступа",
data: { url: "URL" },
},
},
servernotices: {
name: "Уведомления",
send: "Отправить уведомление",
fields: {
body: "Сообщение",
},
action: {
send: "Отправить",
send_success: "Уведомление успешно отправлено.",
send_failure: "Произошла ошибка.",
},
helper: {
send: 'Отправляет уведомление выбранным пользователям. Функция "Server Notices" должна быть активирована на сервере.',
},
},
user_media_statistics: {
name: "Медиа",
fields: {
media_count: "Количество медиафайлов",
media_length: "Длина медиафайлов",
},
},
forward_extremities: {
name: "Перенаправленные заключения",
fields: {
id: "Идентификатор события",
received_ts: "Время",
depth: "Глубина",
state_group: "Группа состояния",
},
},
room_state: {
name: "События изменения состояния",
fields: {
type: "Тип",
content: "Содержание",
origin_server_ts: "время отправления",
sender: "Отправитель",
},
},
room_directory: {
name: "Публичных комнат |||| Публичные комнаты",
fields: {
world_readable: "Видимо для гостей",
guest_can_join: "Гости могут присоединиться",
},
action: {
title:
"Удалить комнату |||| Удалить %{smart_count} комнат",
content:
"Вы уверены, что хотите удалить комнату? |||| Вы уверены, что хотите удалить %{smart_count} комнат?",
erase: "Удалить комнату",
create: "Опубликовать комнату",
send_success: "Комната успешно опубликована.",
send_failure: "Произошла ошибка.",
},
},
destinations: {
name: "Федераций |||| Федерация",
fields: {
destination: "Назначение",
failure_ts: "Время сбоя",
retry_last_ts: "Время последней попытки",
retry_interval: "Интервал повторения",
last_successful_stream_ordering: "Последнее успешное соединение",
stream_ordering: "Соединение",
},
action: { reconnect: "Переподключиться" },
},
},
registration_tokens: {
name: "Токены регистрации",
fields: {
token: "Токен",
valid: "Допустимый токен",
uses_allowed: "Разрешено",
pending: "Ожидается",
completed: "Использован",
expiry_time: "Время истечения срока действия",
length: "Длина",
},
helper: { length: "Длина токена, если токен не указан." },
},
};
export default ru;

View File

@ -10,12 +10,10 @@ const zh = {
username_error: "请输入完整有效的用户 ID: '@user:domain'",
protocol_error: "URL 需要以'http://'或'https://'作为起始",
url_error: "不是一个有效的 Matrix 服务器地址",
sso_sign_in: "使用 SSO 登录",
},
users: {
invalid_user_id:
"必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
tabs: { sso: "SSO" },
},
rooms: {
tabs: {
@ -100,6 +98,7 @@ const zh = {
},
resources: {
users: {
backtolist: "回到列表",
name: "用户",
email: "邮箱",
msisdn: "电话",

5
src/index.js Normal file
View File

@ -0,0 +1,5 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

View File

@ -1,9 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -2,31 +2,20 @@ import { fetchUtils } from "react-admin";
const authProvider = {
// called when the user attempts to log in
login: async ({ base_url, username, password, loginToken }) => {
login: ({ base_url, username, password }) => {
// force homeserver for protection in case the form is manipulated
base_url = process.env.REACT_APP_SERVER || base_url;
console.log("login ");
const options = {
method: "POST",
body: JSON.stringify(
Object.assign(
{
device_id: localStorage.getItem("device_id"),
initial_device_display_name: "Synapse Admin",
},
loginToken
? {
type: "m.login.token",
token: loginToken,
}
: {
type: "m.login.password",
user: username,
password: password,
}
)
),
body: JSON.stringify({
type: "m.login.password",
user: username,
password: password,
device_id: localStorage.getItem("device_id"),
initial_device_display_name: "Synapse Admin",
}),
};
// use the base_url from login instead of the well_known entry from the
@ -38,14 +27,15 @@ const authProvider = {
const decoded_base_url = window.decodeURIComponent(base_url);
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
const { json } = await fetchUtils.fetchJson(login_api_url, options);
localStorage.setItem("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token);
localStorage.setItem("device_id", json.device_id);
return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => {
localStorage.setItem("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token);
localStorage.setItem("device_id", json.device_id);
});
},
// called when the user clicks on the logout button
logout: async () => {
logout: () => {
console.log("logout");
const logout_api_url =
@ -61,9 +51,11 @@ const authProvider = {
};
if (typeof access_token === "string") {
await fetchUtils.fetchJson(logout_api_url, options);
localStorage.removeItem("access_token");
fetchUtils.fetchJson(logout_api_url, options).then(({ json }) => {
localStorage.removeItem("access_token");
});
}
return Promise.resolve();
},
// called when the API returns an error
checkError: ({ status }) => {

View File

@ -1,135 +0,0 @@
import authProvider from "./authProvider";
describe("authProvider", () => {
beforeEach(() => {
fetch.resetMocks();
localStorage.clear();
});
describe("login", () => {
it("should successfully login with username and password", async () => {
fetch.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret = await authProvider.login({
base_url: "http://example.com",
username: "@user:example.com",
password: "secret",
});
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith(
"http://example.com/_matrix/client/r0/login",
{
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}',
headers: new Headers({
Accept: ["application/json"],
"Content-Type": ["application/json"],
}),
method: "POST",
}
);
expect(localStorage.getItem("base_url")).toEqual("http://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar");
expect(localStorage.getItem("device_id")).toEqual("some_device");
});
});
it("should successfully login with token", async () => {
fetch.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret = await authProvider.login({
base_url: "https://example.com/",
loginToken: "login_token",
});
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith(
"https://example.com/_matrix/client/r0/login",
{
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
headers: new Headers({
Accept: ["application/json"],
"Content-Type": ["application/json"],
}),
method: "POST",
}
);
expect(localStorage.getItem("base_url")).toEqual("https://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar");
expect(localStorage.getItem("device_id")).toEqual("some_device");
});
describe("logout", () => {
it("should remove the access_token from localStorage", async () => {
localStorage.setItem("base_url", "example.com");
localStorage.setItem("access_token", "foo");
fetch.mockResponse(JSON.stringify({}));
await authProvider.logout();
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
headers: new Headers({
Accept: ["application/json"],
Authorization: ["Bearer foo"],
}),
method: "POST",
user: { authenticated: true, token: "Bearer foo" },
});
expect(localStorage.getItem("access_token")).toBeNull();
});
});
describe("checkError", () => {
it("should resolve if error.status is not 401 or 403", async () => {
await expect(
authProvider.checkError({ status: 200 })
).resolves.toBeUndefined();
});
it("should reject if error.status is 401", async () => {
await expect(
authProvider.checkError({ status: 401 })
).rejects.toBeUndefined();
});
it("should reject if error.status is 403", async () => {
await expect(
authProvider.checkError({ status: 403 })
).rejects.toBeUndefined();
});
});
describe("checkAuth", () => {
it("should reject when not logged in", async () => {
await expect(authProvider.checkAuth({})).rejects.toBeUndefined();
});
it("should resolve when logged in", async () => {
localStorage.setItem("access_token", "foobar");
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
});
});
describe("getPermissions", () => {
it("should do nothing", async () => {
await expect(authProvider.getPermissions()).resolves.toBeUndefined();
});
});
});

View File

@ -25,6 +25,13 @@ const mxcUrlToHttp = mxcUrl => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const POWER_LEVELS = {
admin: 100,
mod: 50,
user: 0,
};
const roleToPowerLevel = role => POWER_LEVELS[role] || 0;
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
@ -35,22 +42,19 @@ const resourceMap = {
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
displayname: u.display_name || u.displayname,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
create: data => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(
data.id
)}:${localStorage.getItem("home_server")}`,
endpoint: `/_synapse/admin/v2/users/${data.id}`,
body: data,
method: "PUT",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(
params.id
)}`,
endpoint: `/_synapse/admin/v1/deactivate/${params.id}`,
body: { erase: true },
method: "POST",
}),
@ -67,11 +71,43 @@ const resourceMap = {
public: !!r.public,
}),
data: "rooms",
total: json => {
return json.total_rooms;
total: json => json.total_rooms,
create: data => ({
endpoint: "/_synapse/admin/v1/rooms",
body: {
owner: data.owner,
name: data.name,
room_alias_name: data.canonical_alias,
visibility: data.public ? "public" : "private",
invite:
Array.isArray(data.invitees) && data.invitees.length > 0
? data.invitees
: undefined,
initial_state: data.encrypt
? [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
]
: undefined,
},
method: "POST",
}),
transformBeforeUpdate: data => {
return {
...data,
member_roles: (data.member_roles || []).map(member => ({
member_id: member.member_id,
power_level: roleToPowerLevel(member.role),
})),
};
},
delete: params => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
endpoint: `/_synapse/admin/v1/rooms/${params.id}`,
body: { block: false },
}),
},
@ -94,12 +130,10 @@ const resourceMap = {
return json.total;
},
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
endpoint: `/_synapse/admin/v2/users/${id}/devices`,
}),
delete: params => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
params.previousData.user_id
)}/devices/${params.id}`,
endpoint: `/_synapse/admin/v2/users/${params.user_id}/devices/${params.id}`,
}),
},
connections: {
@ -141,7 +175,7 @@ const resourceMap = {
id: p.pushkey,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
endpoint: `/_synapse/admin/v1/users/${id}/pushers`,
}),
data: "pushers",
total: json => {
@ -153,9 +187,7 @@ const resourceMap = {
id: jr,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(
id
)}/joined_rooms`,
endpoint: `/_synapse/admin/v1/users/${id}/joined_rooms`,
}),
data: "joined_rooms",
total: json => {
@ -168,7 +200,7 @@ const resourceMap = {
id: um.media_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
endpoint: `/_synapse/admin/v1/users/${id}/media`,
}),
data: "media",
total: json => {
@ -184,9 +216,9 @@ const resourceMap = {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/delete?before_ts=${params.meta.before_ts}&size_gt=${
params.meta.size_gt
}&keep_profiles=${params.meta.keep_profiles}`,
)}/delete?before_ts=${params.before_ts}&size_gt=${
params.size_gt
}&keep_profiles=${params.keep_profiles}`,
method: "POST",
}),
},
@ -197,7 +229,7 @@ const resourceMap = {
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
endpoint: `/_synapse/admin/v1/media/unprotect/${params.media_id}`,
method: "POST",
}),
},
@ -212,7 +244,7 @@ const resourceMap = {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem(
"home_server"
)}/${params.id}`,
)}/${params.media_id}`,
method: "POST",
}),
},
@ -281,59 +313,11 @@ const resourceMap = {
method: "PUT",
}),
},
destinations: {
path: "/_synapse/admin/v1/federation/destinations",
map: dst => ({
...dst,
id: dst.destination,
}),
data: "destinations",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST",
}),
},
destination_rooms: {
map: dstroom => ({
...dstroom,
id: dstroom.room_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}),
data: "rooms",
total: json => {
return json.total;
},
},
registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens",
map: rt => ({
...rt,
id: rt.token,
}),
data: "registration_tokens",
total: json => {
return json.registration_tokens.length;
},
create: params => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}),
},
};
function filterNullValues(key, value) {
// Filtering out null properties
// to reset user_type from user, it must be null
if (value === null && key !== "user_type") {
if (value === null) {
return undefined;
}
return value;
@ -348,17 +332,9 @@ function getSearchOrder(order) {
}
const dataProvider = {
getList: async (resource, params) => {
getList: (resource, params) => {
console.log("getList " + resource);
const {
user_id,
name,
guests,
deactivated,
search_term,
destination,
valid,
} = params.filter;
const { user_id, name, guests, deactivated, search_term } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
@ -368,10 +344,8 @@ const dataProvider = {
user_id: user_id,
search_term: search_term,
name: name,
destination: destination,
guests: guests,
deactivated: deactivated,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
};
@ -383,28 +357,26 @@ const dataProvider = {
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const { json } = await jsonClient(url);
return {
return jsonClient(url).then(({ json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
}));
},
getOne: async (resource, params) => {
console.log("getOne " + resource);
getOne: (resource, params) => {
console.log("getOne " + resource, params);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(
`${endpoint_url}/${encodeURIComponent(params.id)}`
);
return { data: res.map(json) };
return jsonClient(`${endpoint_url}/${params.id}`).then(({ json }) => ({
data: res.map(json),
}));
},
getMany: async (resource, params) => {
getMany: (resource, params) => {
console.log("getMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -412,18 +384,15 @@ const dataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
)
);
return {
return Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`))
).then(responses => ({
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
};
}));
},
getManyReference: async (resource, params) => {
getManyReference: (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
@ -443,32 +412,32 @@ const dataProvider = {
const ref = res["reference"](params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const { json } = await jsonClient(endpoint_url);
return {
return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
}));
},
update: async (resource, params) => {
update: (resource, params) => {
console.log("update " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const transform = res.transformBeforeUpdate || (x => x);
const data = transform(params.data);
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(
`${endpoint_url}/${encodeURIComponent(params.id)}`,
{
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}
);
return { data: res.map(json) };
return jsonClient(`${endpoint_url}/${params.data.id}`, {
method: "PUT",
body: JSON.stringify(data, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
},
updateMany: async (resource, params) => {
updateMany: (resource, params) => {
console.log("updateMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -476,19 +445,17 @@ const dataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
{
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}
)
);
return { data: responses.map(({ json }) => json) };
return Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`), {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
create: async (resource, params) => {
create: (resource, params) => {
console.log("create " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -498,14 +465,15 @@ const dataProvider = {
const create = res["create"](params.data);
const endpoint_url = homeserver + create.endpoint;
const { json } = await jsonClient(endpoint_url, {
return jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
});
return { data: res.map(json) };
}).then(({ json }) => ({
data: res.map(json),
}));
},
createMany: async (resource, params) => {
createMany: (resource, params) => {
console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -513,7 +481,7 @@ const dataProvider = {
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const responses = await Promise.all(
return Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res["create"](params.data);
@ -523,11 +491,12 @@ const dataProvider = {
body: JSON.stringify(cre.body, filterNullValues),
});
})
);
return { data: responses.map(({ json }) => json) };
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
delete: async (resource, params) => {
delete: (resource, params) => {
console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -537,22 +506,24 @@ const dataProvider = {
if ("delete" in res) {
const del = res["delete"](params);
const endpoint_url = homeserver + del.endpoint;
const { json } = await jsonClient(endpoint_url, {
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
return { data: json };
}).then(({ json }) => ({
data: json,
}));
} else {
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${params.id}`, {
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues),
});
return { data: json };
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: json,
}));
}
},
deleteMany: async (resource, params) => {
deleteMany: (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -560,7 +531,7 @@ const dataProvider = {
const res = resourceMap[resource];
if ("delete" in res) {
const responses = await Promise.all(
return Promise.all(
params.ids.map(id => {
const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
@ -569,21 +540,21 @@ const dataProvider = {
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
);
return {
).then(responses => ({
data: responses.map(({ json }) => json),
};
}));
} else {
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
})
)
);
return { data: responses.map(({ json }) => json) };
).then(responses => ({
data: responses.map(({ json }) => json),
}));
}
},
};

View File

@ -1,55 +0,0 @@
import { fetchUtils } from "react-admin";
export const splitMxid = mxid => {
const re =
/^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups;
};
export const isValidBaseUrl = baseUrl =>
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/**
* Resolve the homeserver URL using the well-known lookup
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async domain => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const json = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
return json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
return `https://${domain}`;
}
};
/**
* Get synapse server version
* @param base_url the base URL of the homeserver
* @returns server version
*/
export const getServerVersion = async baseUrl => {
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json.server_version;
};
/** Get supported Matrix features */
export const getSupportedFeatures = async baseUrl => {
const versionUrl = `${baseUrl}/_matrix/client/versions`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json;
};
/**
* Get supported login flows
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async baseUrl => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
return response.json.flows;
};

View File

@ -1,31 +0,0 @@
import { isValidBaseUrl, splitMxid } from "./synapse";
describe("splitMxid", () => {
it("splits valid MXIDs", () =>
expect(splitMxid("@name:domain.tld")).toEqual({
name: "name",
domain: "domain.tld",
}));
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
});
describe("isValidBaseUrl", () => {
it("accepts a http URL", () =>
expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
it("accepts a https URL", () =>
expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
it("accepts a valid URL with port", () =>
expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
it("rejects undefined base URLs", () =>
expect(isValidBaseUrl(undefined)).toBeFalsy());
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
it("rejects non-string base URLs", () =>
expect(isValidBaseUrl({})).toBeFalsy());
it("rejects base URLs without protocol", () =>
expect(isValidBaseUrl("foo.bar")).toBeFalsy());
it("rejects base URLs with path", () =>
expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
it("rejects invalid base URLs", () =>
expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
});

14425
yarn.lock

File diff suppressed because it is too large Load Diff