Compare commits

26 Commits

Author SHA1 Message Date
Aine
0594259ae4 make avatar field sortable in the users list 2024-10-08 12:15:03 +03:00
Aine
ba485bbb18 Merge branch 'master' 2024-10-08 10:51:16 +03:00
Dirk Klimpel
9fc005032c Fix for empty user default tab after creation (#628) 2024-10-08 09:20:55 +02:00
Aine
f5d6f24b30 correctly treat empty or "almost empty" restrictBaseUrl config option, fixes #56 2024-10-07 12:00:33 +03:00
Aine
a42efe7eda do not color failed federation destinations, use an icon instead 2024-10-04 00:16:11 +03:00
Borislav Pantaleev
31d3712dbb Better media preview/download implementation (#53)
* Add download option for non image media

* make view media button change the icon during the loading; update readme
2024-10-03 11:23:51 +03:00
Aine
a15dad4a31 ensure preview works for images only (for now) 2024-10-03 01:18:45 +03:00
Borislav Pantaleev
9062a6afb1 Remove Dialog window for image preview, open all media in new tab 2024-10-03 00:52:07 +03:00
Borislav Pantaleev
a79c3597d6 Authenticated Media Support (#51)
* Fix AvatarField to work with authenticated media

* Fix ViewMediaButton to work for user's media tab and reported events

* remove console.log

* cleanup AvatarField

* use correct thumbnail size

* fix AvatarField.test

* ignore postgres data for watchman

* fix new avatar preview

* watchman should ignore testdata completely, instead of specific subdirs

* update README

* change user's media icon in sidebar - use the same icon as the media tab

* Add preview for user media files if mimeType is image/*

* Add new line in user media Dialog
2024-10-03 00:38:35 +03:00
Aine
470f1b5455 adjust 'redact all user events on remove' to the new API 2024-10-02 23:34:07 +03:00
Aine
46cc0e2fda refactor users' after delete to react-admin lifecycle hooks, fixes #50 2024-09-29 19:46:00 +03:00
Aine
f3080e9468 Provide options to delete media and redact events on user erase (#49)
* Provide options to delete media and redact events on user erase

* update readme
2024-09-29 00:16:44 +03:00
Aine
f8fe1166e2 Do not block deleted rooms from being rejoined by default (UI option), fixes #48 2024-09-27 19:25:15 +03:00
Borislav Pantaleev
02bee92806 Fix missing QueryClient (#47) 2024-09-26 21:42:09 +00:00
Aine
b7f8e03894 update README 2024-09-25 19:26:26 +03:00
Aine
59bb4b4183 update README 2024-09-25 19:10:43 +03:00
Aine
bb53d53692 Add Contact support button (#45) 2024-09-25 19:09:58 +03:00
Aine
52a2f1c936 reword managed user error 2024-09-24 21:58:24 +03:00
Aine
e328380c77 Restrict actions on specific users (#42)
* Restrict actions on specific users

* update readme
2024-09-24 13:25:29 +03:00
dependabot[bot]
a277ded227 Bump rollup from 4.22.0 to 4.22.4 (#41)
Bumps [rollup](https://github.com/rollup/rollup) from 4.22.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.22.0...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 10:18:09 +03:00
Borislav Pantaleev
48d933e028 Upgrade react-admin to version 5 (#40)
* wip on ra upgrade

* Finish upgrade

* fix package.json

* pr fixes

* more pr fixes

* update readme
2024-09-24 10:02:47 +03:00
Aine
24cf0a60bf Expose user avatar URL field in the UI (#27)
* wip

* some fixes

* update readme

* update readme

* Add option to change/erase any user's avatar

* Fix README

* Remove mutationMode from Edit

* remove log

* update readme
2024-09-17 23:06:12 +03:00
dependabot[bot]
d5113aad72 Bump dompurify from 2.5.0 to 2.5.6 (#35)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.5.0 to 2.5.6.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.5.0...2.5.6)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-17 20:34:41 +03:00
Aine
6957cb1f7d Fix Bulk registration invalid MXID in requests, fixes #30 (#33)
* Fix Bulk registration invalid MXID in requests

* update readme
2024-09-17 14:02:41 +03:00
Aine
66c706532a Fix required fields check on Bulk registration CSV upload, fixes #29 (#32) 2024-09-17 13:17:07 +03:00
Aine
332e98a095 add dev stack 2024-09-17 12:54:29 +03:00
50 changed files with 7112 additions and 8871 deletions

View File

@@ -1,16 +0,0 @@
# Contributing to [etkecc/synapse-admin](https://github.com/etkecc/synapse-admin)
While etke.cc fork is intended to accept more QoL changes and features,
it's good idea to open PR into the upstream repo: [Awesome-Technologies/Synapse-Admin](https://github.com/Awesome-Technologies/synapse-admin).
1. Use the etkecc/synapse-admin **master** branch as your branch upstream: `git checkout master; git pull; git checkout -b my-new-feature`
2. Once your changes are ready, please, open **2** PRs: one from your branch to `Awesome-Technologies/Synapse-Admin` **master**, and another one to `etkecc/synapse-admin` **main**
3. Once PR is accepted in the `etkecc/synapse-admin`, update `README.md` file (either directly in the `main` branch, or via another PR) to add link to the merged PR in the [Fork differences](https://github.com/etkecc/synapse-admin#fork-differences) section
### Why?
The upstream project may not want to accept all the changes, so to ensure they are not lost, we will gladly add them to the etke.cc fork.
Unfortunately, it's challenging to keep changes separated, so to avoid messing upstream and fork changes (e.g., CI changes that should not be pushed to the upstream, as they intended for this fork specifically), there are 2 branches:
* `master` - read-only copy of upstream's master branch to easily sync changes, and use it as base for new PRs
* `main` - fork-own branch with all changes

3
.gitignore vendored
View File

@@ -191,3 +191,6 @@ sketch
# .pnp.*
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
/testdata/synapse.data
/testdata/postgres.data

View File

@@ -5,6 +5,6 @@
},
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

5
.watchmanconfig Normal file
View File

@@ -0,0 +1,5 @@
{
"ignore_dirs": [
"testdata"
]
}

View File

@@ -1 +0,0 @@
yarnPath: .yarn/releases/yarn-4.1.1.cjs

251
README.md
View File

@@ -1,26 +1,44 @@
[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
# Synapse Admin UI [![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
# Synapse admin ui
![Screenshots](./screenshots.jpg)
This project is built using [react-admin](https://marmelab.com/react-admin/).
<!-- vim-markdown-toc GFM -->
* [Fork differences](#fork-differences)
* [Available via CDN](#available-via-cdn)
* [Changes](#changes)
* [Development](#development)
* [Configuration](#configuration)
* [Restricting available homeserver](#restricting-available-homeserver)
* [Protecting appservice managed users](#protecting-appservice-managed-users)
* [Providing support URL](#providing-support-url)
* [Usage](#usage)
* [Supported Synapse](#supported-synapse)
* [Prerequisites](#prerequisites)
* [Use without install](#use-without-install)
* [Step-By-Step install](#step-by-step-install)
* [Steps for 1)](#steps-for-1)
* [Steps for 2)](#steps-for-2)
* [Steps for 3)](#steps-for-3)
* [Serving Synapse-Admin on a different path](#serving-synapse-admin-on-a-different-path)
* [Development](#development-1)
<!-- vim-markdown-toc -->
## Fork differences
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
user-friendly interface for managing Synapse homeservers.
### Available via CDN
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
### Changes
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
user-friendly interface for managing Synapse homeservers.
The following changes are already implemented:
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
@@ -36,87 +54,43 @@ The following changes are already implemented:
* [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (CI only)
* [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using theme colors)
* [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
* [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
* [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
* [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
* [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53)
_the list will be updated as new changes are added_
## Usage
### Development
### Supported Synapse
`just run-dev` to start the development stack (depending on your system speed, you may want to re-run this command if
user creation fails)
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected!
After that open `http://localhost:5173` in your browser, login using the following credentials:
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).
* Login: admin
* Password: admin
* Homeserver URL: http://localhost:8008
After entering the URL on the login page of synapse-admin the server version appears below the input field.
## Configuration
### Prerequisites
You can use `config.json` file to configure synapse-admin
You need access to the following endpoints:
The `config.json` can be injected into a Docker container using a bind mount.
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
### Use without install
You can use the current version of Synapse Admin without own installation direct
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
**Note:**
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
**Remember: You have no need to expose these endpoints to the internet but to your network.**
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
### Step-By-Step install
You have three options:
1. [Download the tarball and serve with any webserver](#steps-for-1)
2. [Download the source code from github and run using nodejs](#steps-for-2)
3. [Run the Docker container](#steps-for-3)
#### Steps for 1)
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
- configure a vhost for synapse admin on your webserver
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest
- unpack the .tar.gz
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
- open the url of the vhost in your browser
#### Steps for 2)
- make sure you have installed the following: git, yarn, nodejs
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
- change into downloaded directory: `cd synapse-admin`
- download dependencies: `yarn install`
- start web server: `yarn start`
#### Steps for 3)
- run the Docker container from the public docker registry: `docker run -p 8080:80 awesometechnologies/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
```yml
services:
synapse-admin:
container_name: synapse-admin
hostname: synapse-admin
build:
context: https://github.com/Awesome-Technologies/synapse-admin.git
args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# - NODE_OPTIONS="--max_old_space_size=1024"
# - BASE_PATH="/synapse-admin"
ports:
- "8080:80"
restart: unless-stopped
```
- browse to http://localhost:8080
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
```
### Restricting available homeserver
@@ -138,17 +112,108 @@ or to a list of homeservers:
}
```
The `config.json` can be injected into a Docker container using a bind mount.
### Protecting appservice managed users
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
you can specify the list of MXIDs (regexp) that should be prohibited from any changes, except display name and avatar.
Example for [mautrix-telegram](https://github.com/mautrix/telegram)
```json
{
"asManagedUsers": ["^@telegram_[a-zA-Z0-9]+:example\\.com$"]
}
```
### Providing support URL
Synapse-Admin provides a support link in the main menu - `Contact support`. By default, the link points to the GitHub issues page of the project. You can change this link by providing a `supportURL` in the `config.json`.
```json
{
"supportURL": "https://example.com/support"
}
```
## Usage
### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.116.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
After entering the URL on the login page of synapse-admin the server version appears below the input field.
### Prerequisites
You need access to the following endpoints:
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
### Use without install
You can use the current version of Synapse Admin without own installation direct
via [admin.etke.cc](https://admin.etke.cc).
**Note:**
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
**Remember: You have no need to expose these endpoints to the internet but to your network.**
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
### Step-By-Step install
You have three options:
1. [Download the tarball and serve with any webserver](#steps-for-1)
2. [Download the source code from github and run using nodejs](#steps-for-2)
3. [Run the Docker container](#steps-for-3)
#### Steps for 1)
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
- configure a vhost for synapse admin on your webserver
- download the .tar.gz [from the latest release](https://github.com/etkecc/synapse-admin/releases/latest)
- unpack the .tar.gz
- move or symlink the `synapse-admin` into your vhosts root dir
- open the url of the vhost in your browser
#### Steps for 2)
- make sure you have installed the following: git, yarn, nodejs
- download the source code: `git clone https://github.com/etkecc/synapse-admin.git`
- change into downloaded directory: `cd synapse-admin`
- download dependencies: `yarn install`
- start web server: `yarn start`
#### Steps for 3)
- run the Docker container from the public docker registry: `docker run -p 8080:80 ghcr.io/etkecc/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
```yml
services:
synapse-admin:
container_name: synapse-admin
hostname: synapse-admin
build:
context: https://github.com/etkecc/synapse-admin.git
args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# - NODE_OPTIONS="--max_old_space_size=1024"
# - BASE_PATH="/synapse-admin"
ports:
- "8080:80"
restart: unless-stopped
```
- browse to http://localhost:8080
### Serving Synapse-Admin on a different path
The path prefix where synapse-admin is served can only be changed during the build step.
@@ -175,7 +240,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
synapse-admin:
image: awesometechnologies/synapse-admin:latest
image: etkecc/synapse-admin:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
@@ -186,10 +251,6 @@ services:
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
```
## Screenshots
![Screenshots](./screenshots.jpg)
## Development
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE

20
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
synapse:
image: ghcr.io/element-hq/synapse:latest
entrypoint: python
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
ports:
- "8008:8008"
volumes:
- ./testdata/synapse:/config
- ./testdata/synapse.data:/media-store
postgres:
image: postgres:alpine
volumes:
- ./testdata/postgres.data:/var/lib/postgresql/data
environment:
POSTGRES_USER: synapse
POSTGRES_PASSWORD: synapse
POSTGRES_DB: synapse
POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"

View File

@@ -6,8 +6,37 @@ default:
build: __install
@yarn run build --base=./
# run the app in a development mode
run:
@yarn start --host 0.0.0.0
# run dev stack and start the app in a development mode
run-dev:
@echo "Starting the database..."
@docker-compose -f docker-compose-dev.yml up -d postgres
@echo "Starting Synapse..."
@docker-compose -f docker-compose-dev.yml up -d synapse
@echo "Ensure admin user is registered..."
@docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
@echo "Starting the app..."
@yarn start --host 0.0.0.0
# stop the dev stack
stop-dev:
@docker-compose -f docker-compose-dev.yml stop
# register a user in the dev stack
register-user localpart password *admin:
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
# run yarn {fix,lint,test} commands
test:
@-yarn run fix
@-yarn run lint
@-yarn run test
# run the app in a production mode
run: build
run-prod: build
@python -m http.server -d dist 1313
# install the project

View File

@@ -10,21 +10,20 @@
"type": "git",
"url": "https://github.com/etkecc/synapse-admin"
},
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@eslint/js": "^9.7.0",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.12",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
@@ -37,11 +36,11 @@
"jest-fetch-mock": "^3.0.3",
"prettier": "^3.3.3",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.2.3",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.16.1",
"vite": "^5.3.4",
"vite": "^5.4.6",
"vite-plugin-version-mark": "^0.1.0"
},
"dependencies": {
@@ -49,27 +48,26 @@
"@emotion/styled": "^11.13.0",
"@haleos/ra-language-german": "^1.0.0",
"@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^5.16.4",
"@mui/material": "^5.16.4",
"@mui/icons-material": "^6.1.1",
"@mui/material": "^6.1.1",
"@tanstack/react-query": "^5.56.2",
"history": "^5.3.0",
"lodash": "^4.17.21",
"papaparse": "^5.4.1",
"query-string": "^7.1.3",
"ra-core": "^4.16.20",
"ra-i18n-polyglot": "^4.16.20",
"ra-language-english": "^4.16.20",
"ra-language-farsi": "^4.2.0",
"ra-language-french": "^4.16.20",
"ra-core": "^5.2.0",
"ra-i18n-polyglot": "^5.2.0",
"ra-language-english": "^5.2.0",
"ra-language-farsi": "^5.0.0",
"ra-language-french": "^5.2.0",
"ra-language-italian": "^3.13.1",
"ra-language-russian": "^4.14.2",
"react": "^18.3.1",
"react-admin": "^4.16.20",
"react-admin": "^5.2.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.1",
"react-hook-form": "^7.53.0",
"react-is": "^18.3.1",
"react-query": "^3.39.3",
"react-router": "^6.25.1",
"react-router-dom": "^6.25.1"
"react-router": "^6.26.2",
"react-router-dom": "^6.26.2"
},
"scripts": {
"start": "vite serve",

View File

@@ -1,4 +1,6 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
import App from "./App";
@@ -7,4 +9,4 @@ describe("App", () => {
render(<App />);
await screen.findAllByText("Welcome to Synapse-admin");
});
});
});

View File

@@ -4,6 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
import { Route } from "react-router-dom";
import { AdminLayout } from "./components/AdminLayout";
import { ImportFeature } from "./components/ImportFeature";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
@@ -21,6 +22,7 @@ import userMediaStats from "./resources/user_media_statistics";
import users from "./resources/users";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
@@ -45,37 +47,41 @@ const i18nProvider = polyglotI18nProvider(
]
);
const queryClient = new QueryClient();
const App = () => (
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
darkTheme={{ palette: { mode: "dark" } }}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
<QueryClientProvider client={queryClient}>
<Admin
disableTelemetry
requireAuth
layout={AdminLayout}
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 {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
</QueryClientProvider>
);
export default App;

View File

@@ -2,6 +2,8 @@ import { createContext, useContext } from "react";
interface AppContextType {
restrictBaseUrl: string | string[];
asManagedUsers: string[];
supportURL: string;
}
export const AppContext = createContext({});

View File

@@ -0,0 +1,26 @@
import { Layout, Menu } from 'react-admin';
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
const DEFAULT_SUPPORT_LINK = "https://github.com/etkecc/synapse-admin/issues";
const supportLink = (): string => {
try {
new URL(localStorage.getItem("support_url") || ''); // Check if the URL is valid
return localStorage.getItem("support_url") || DEFAULT_SUPPORT_LINK;
} catch (e) {
return DEFAULT_SUPPORT_LINK;
}
};
const AdminMenu = () => (
<Menu>
<Menu.ResourceItems />
<Menu.Item to={supportLink()} target="_blank" primaryText="Contact support" leftIcon={<LiveHelpIcon />} />
</Menu>
);
export const AdminLayout = ({ children }) => (
<Layout menu={AdminMenu}>
{children}
</Layout>
);

View File

@@ -1,18 +1,43 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { RecordContextProvider } from "react-admin";
import { act } from "react";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
it("shows image", () => {
beforeEach(() => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
})
) as jest.Mock;
// Mock URL.createObjectURL
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
});
afterEach(() => {
jest.restoreAllMocks();
});
it.only("shows image", async () => {
const value = {
avatar: "foo",
avatar: "mxc://serverName/mediaId",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
await act(async () => {
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
});
await waitFor(() => {
const img = screen.getByRole("img");
expect(img.getAttribute("src")).toBe("mock-object-url");
});
expect(global.fetch).toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,36 @@
import { get } from "lodash";
import { Avatar, AvatarProps } from "@mui/material";
import { FieldProps, useRecordContext } from "react-admin";
import { useState, useEffect, useCallback } from "react";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin";
const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
const { alt, classes, sizes, sx, variant } = rest;
const record = useRecordContext(rest);
const mxcURL = get(record, source)?.toString();
const [src, setSrc] = useState<string>("");
const fetchAvatar = useCallback(async (mxcURL: string) => {
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
setSrc(blobURL);
}, []);
useEffect(() => {
if (mxcURL) {
fetchAvatar(mxcURL);
}
// Cleanup function to revoke the object URL
return () => {
if (src) {
URL.revokeObjectURL(src);
}
};
}, [mxcURL, fetchAvatar]);
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
};

View File

@@ -16,7 +16,7 @@ const resourceName = "rooms";
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
const translate = useTranslate();
const [open, setOpen] = useState(false);
const [block, setBlock] = useState(true);
const [block, setBlock] = useState(false);
const notify = useNotify();
const redirect = useRedirect();
@@ -74,12 +74,11 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
<SimpleForm toolbar={false}>
<BooleanInput
fullWidth
source="block"
value={block}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
label="resources.rooms.action.erase.fields.block"
defaultValue={true}
defaultValue={false}
/>
</SimpleForm>
</DialogContent>

View File

@@ -0,0 +1,117 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { Fragment, useState } from "react";
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
import ActionDelete from "@mui/icons-material/Delete";
import ActionCheck from "@mui/icons-material/CheckCircle";
import AlertError from "@mui/icons-material/ErrorOutline";
interface DeleteUserButtonProps {
selectedIds: Identifier[];
confirmTitle: string;
confirmContent: string;
}
const resourceName = "users";
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
const translate = useTranslate();
const [open, setOpen] = useState(false);
const [deleteMedia, setDeleteMedia] = useState(false);
const [redactEvents, setRedactEvents] = useState(false);
const notify = useNotify();
const redirect = useRedirect();
const [deleteMany, { isLoading }] = useDeleteMany();
const unselectAll = useUnselectAll(resourceName);
const recordIds = props.selectedIds;
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
deleteMany(
resourceName,
{ ids: recordIds, meta: values },
{
onSuccess: () => {
notify("ra.notification.deleted", {
messageArgs: {
smart_count: recordIds.length,
},
type: 'info' as NotificationType,
});
handleDialogClose();
unselectAll();
redirect("/users");
},
onError: (error) =>
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
}
);
};
const handleConfirm = () => {
setOpen(false);
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
};
return (
<Fragment>
<Button
onClick={handleDialogOpen}
disabled={isLoading}
className={"ra-delete-button"}
key="button"
size="small"
sx={{
"&.MuiButton-sizeSmall": {
lineHeight: 1.5,
},
}}
color={"error"}
startIcon={<ActionDelete />}
>
{translate("ra.action.delete")}
</Button>
<Dialog open={open} onClose={handleDialogClose}>
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
<DialogContent>
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
<SimpleForm toolbar={false}>
<BooleanInput
source="deleteMedia"
value={deleteMedia}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
label="resources.users.action.delete_media"
defaultValue={false}
/>
<BooleanInput
source="redactEvents"
value={redactEvents}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
label="resources.users.action.redact_events"
defaultValue={false}
/>
</SimpleForm>
</DialogContent>
<DialogActions>
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
{translate("ra.action.cancel")}
</Button>
<Button
disabled={false}
onClick={handleConfirm}
className={"ra-confirm RaConfirm-confirmPrimary"}
autoFocus
startIcon={<ActionCheck />}
>
{translate("ra.action.confirm")}
</Button>
</DialogActions>
</Dialog>
</Fragment>
);
};
export default DeleteUserButton;

View File

@@ -15,7 +15,7 @@ import {
import { DataProvider, useTranslate } from "ra-core";
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
import { generateRandomMxId, generateRandomPassword, returnMXID } from "../synapse/synapse";
const LOGGING = true;
@@ -74,7 +74,7 @@ const FilePicker = () => {
const [conflictMode, setConflictMode] = useState("stop");
const [passwordMode, setPasswordMode] = useState(true);
const [useridMode, setUseridMode] = useState("ignore");
const [useridMode, setUseridMode] = useState("update");
const translate = useTranslate();
const notify = useNotify();
@@ -121,7 +121,11 @@ const FilePicker = () => {
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
/* First, verify the presence of required fields */
const missingFields = expectedFields.filter(eF => meta.fields?.find(mF => eF === mF));
const missingFields = expectedFields.filter(eF => {
const result = meta.fields?.find(mF => eF === mF);
if (result === undefined) { return eF; } // missing field
return undefined; // field found
});
if (missingFields.length > 0) {
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
@@ -262,12 +266,15 @@ const FilePicker = () => {
const userRecord = { ...entry };
// No need to do a bunch of cryptographic random number getting if
// we are using neither a generated password nor a generated user id.
if (useridMode === "ignore" || userRecord.id === undefined) {
if (useridMode === "ignore" || userRecord.id === undefined || userRecord.id === "") {
userRecord.id = generateRandomMxId();
}
if (passwordMode === false || entry.password === undefined) {
if (passwordMode === false || entry.password === undefined || entry.password === "") {
userRecord.password = generateRandomPassword();
}
// we want to ensure that the ID is always full MXID, otherwise randomly-generated MXIDs will be in the full
// form, but the ones from the CSV will be localpart-only.
userRecord.id = returnMXID(userRecord.id);
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
/* For these modes we will consider the ID that's in the record.

View File

@@ -20,7 +20,7 @@ import {
useTranslate,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
@@ -43,7 +43,6 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
<TextInput
source="body"
label="resources.servernotices.fields.body"
fullWidth
multiline
rows="4"
resettable
@@ -64,6 +63,10 @@ export const ServerNoticeButton = () => {
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
if (!record) {
return null;
}
const handleSend = (values: Partial<RaRecord>) => {
create(
"servernotices",
@@ -100,28 +103,26 @@ export const ServerNoticeBulkButton = () => {
const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider();
const { mutate: sendNotices, isLoading } = useMutation(
data =>
const { mutate: sendNotices, isPending } = useMutation({
mutationFn: (data) =>
dataProvider.createMany("servernotices", {
ids: selectedIds,
data: data,
}),
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
});
return (
<>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
<MessageIcon />
</Button>
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />

View File

@@ -1,9 +1,15 @@
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
import { isASManaged } from "./mxid";
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
const record = useRecordContext();
if (!record) return null;
let isASManagedUser = false;
if (record.user_id) {
isASManagedUser = isASManaged(record.user_id);
}
return (
<DeleteWithConfirmButton
{...props}
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
confirmContent="resources.devices.action.erase.content"
mutationMode="pessimistic"
redirect={false}
disabled={isASManagedUser}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,

View File

@@ -8,7 +8,9 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
import DownloadIcon from '@mui/icons-material/Download';
import DownloadingIcon from '@mui/icons-material/Downloading';
import { Grid2 as Grid, Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import {
BooleanInput,
@@ -28,13 +30,12 @@ import {
useRefresh,
useTranslate,
} from "react-admin";
import { useMutation } from "react-query";
import { Link } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { dateParser } from "./date";
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
@@ -55,14 +56,12 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput
fullWidth
source="before_ts"
label="delete_media.fields.before_ts"
defaultValue={0}
parse={dateParser}
/>
<NumberInput
fullWidth
source="size_gt"
label="delete_media.fields.size_gt"
defaultValue={0}
@@ -70,7 +69,6 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
step={1024}
/>
<BooleanInput
fullWidth
source="keep_profiles"
label="delete_media.fields.keep_profiles"
defaultValue={true}
@@ -86,20 +84,18 @@ export const DeleteMediaButton = (props: ButtonProps) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const dataProvider = useDataProvider<SynapseDataProvider>();
const { mutate: deleteMedia, isLoading } = useMutation(
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
{
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
}
);
const { mutate: deleteMedia, isPending } = useMutation({
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
});
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
@@ -110,7 +106,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
{...props}
label="delete_media.action.send"
onClick={openDialog}
disabled={isLoading}
disabled={isPending}
sx={{
color: theme.palette.error.main,
"&:hover": {
@@ -316,48 +312,104 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
);
};
export const ViewMediaButton = ({ media_id, label }) => {
export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
const translate = useTranslate();
const url = getMediaUrl(media_id);
const [loading, setLoading] = useState(false);
const isImage = mimetype && mimetype.startsWith("image/");
const openFileInNewTab = (blobURL: string) => {
const anchorElement = document.createElement("a");
anchorElement.href = blobURL;
anchorElement.target = "_blank";
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
};
const downloadFile = async (blobURL: string) => {
console.log("downloadFile", blobURL, uploadName);
const anchorElement = document.createElement("a");
anchorElement.href = blobURL;
anchorElement.download = uploadName;
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
setTimeout(() => URL.revokeObjectURL(blobURL), 10);;
};
const handleFile = async (preview: boolean) => {
setLoading(true);
const response = await fetchAuthenticatedMedia(mxcURL, "original");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
if (preview) {
openFileInNewTab(blobURL);
} else {
downloadFile(blobURL);
}
setLoading(false);
};
return (
<Box style={{ whiteSpace: "pre" }}>
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
<Button
component={Link}
to={url}
target="_blank"
rel="noopener"
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
>
<FileOpenIcon />
</Button>
</span>
</Tooltip>
{label}
</Box>
<>
<Box display="flex" alignItems="center">
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
{isImage && (
<Button
disabled={loading}
onClick={() => handleFile(true)}
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
>
{loading ? <DownloadingIcon /> : <FileOpenIcon />}
</Button>
)}
</span>
</Tooltip>
<Button
disabled={loading}
onClick={() => handleFile(false)}
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
>
{loading ? <DownloadingIcon /> : <DownloadIcon />}
</Button>
<span>{label}</span>
</Box>
</>
);
};
export const MediaIDField = ({ source }) => {
const record = useRecordContext();
if (!record) {
return null;
}
const homeserver = storage.getItem("home_server");
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString();
if (!src) return null;
const mediaID = get(record, source)?.toString();
if (!mediaID) {
return null;
}
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
const mxcURL = `mxc://${homeserver}/${mediaID}`;
return <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type}/>;
};
export const MXCField = ({ source }) => {
export const ReportMediaContent = ({ source }) => {
const record = useRecordContext();
if (!record) return null;
if (!record) {
return null;
}
const src = get(record, source)?.toString();
if (!src) return null;
const mxcURL = get(record, source)?.toString();
if (!mxcURL) {
return null;
}
const media_id = src.replace("mxc://", "");
const uploadName = decodeURIComponent(get(record, "event_json.content.body")?.toString());
return <ViewMediaButton media_id={media_id} label={src} />;
return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type}/>;
};

16
src/components/mxid.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Identifier } from "ra-core";
/**
* Check if a user is managed by an application service
* @param id The user ID to check
* @returns Whether the user is managed by an application service
*/
export const isASManaged = (id: string | Identifier): boolean => {
const managedUsersString = localStorage.getItem("as_managed_users") || '';
try {
const asManagedUsers = JSON.parse(managedUsersString).map(regex => new RegExp(regex));
return asManagedUsers.some(regex => regex.test(id));
} catch (e) {
return false;
}
};

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = {
...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: {
auth: {
base_url: "Heimserver URL",
@@ -142,11 +150,16 @@ const de: SynapseTranslationMessages = {
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",
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt",
erase: "DSGVO konformes Löschen der Benutzerdaten.",
erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.",
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
},
action: {
erase: "Lösche Benutzerdaten",
erase_avatar: "Avatar löschen",
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
},
},
rooms: {

View File

@@ -142,10 +142,15 @@ const en: SynapseTranslationMessages = {
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",
erase_text: "This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.",
erase_admin_error: "Deleting own user is not allowed.",
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
},
action: {
erase: "Erase user data",
erase_avatar: "Erase avatar",
delete_media: "Delete all media uploaded by the user(-s)",
redact_events: "Redact all events sent by the user(-s)",
},
},
rooms: {

View File

@@ -138,9 +138,15 @@ const fa: SynapseTranslationMessages = {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
},
action: {
erase: "پاک کردن اطلاعات کاربر",
erase_avatar: "محو الصورة الرمزية",
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
},
},
rooms: {

View File

@@ -140,10 +140,15 @@ const fr: SynapseTranslationMessages = {
helper: {
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.",
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
},
action: {
erase: "Effacer les données de l'utilisateur",
erase_avatar: "Effacer l'avatar",
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
},
},
rooms: {

5
src/i18n/index.d.ts vendored
View File

@@ -138,10 +138,15 @@ interface SynapseTranslationMessages extends TranslationMessages {
password?: string;
deactivate: string;
erase: string;
erase_text: string;
erase_admin_error: string;
modify_managed_user_error: string;
};
action: {
erase: string;
erase_avatar: string;
delete_media: string;
redact_events: string;
};
};
rooms: {

View File

@@ -139,10 +139,15 @@ const it: SynapseTranslationMessages = {
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",
erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
erase_admin_error: "Non è consentito eliminare il proprio utente.",
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
},
action: {
erase: "Cancella i dati dell'utente",
erase_admin_error: "Non è consentito eliminare il proprio utente.",
erase_avatar: "Cancella l'avatar dell'utente",
delete_media: "Elimina tutti i media caricati dall'utente(-s)",
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
},
},
rooms: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = {
...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: {
auth: {
base_url: "Адрес домашнего сервера",
@@ -151,10 +159,15 @@ const ru: SynapseTranslationMessages = {
password: "Смена пароля завершит все сессии пользователя.",
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
erase_admin_error: "Удаление собственного пользователя запрещено.",
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
},
action: {
erase: "Удалить данные пользователя",
erase_avatar: "Удалить аватар",
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
},
},
rooms: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = {
...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: {
auth: {
base_url: "服务器 URL",
@@ -135,10 +143,15 @@ const zh: SynapseTranslationMessages = {
helper: {
deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
erase_admin_error: "不允许删除自己的用户",
modify_managed_user_error: "不允许修改系统管理的用户。",
},
action: {
erase: "抹除用户信息",
erase_avatar: "抹掉头像",
delete_media: "删除用户上传的所有媒体",
redact_events: "重新编辑用户(-s发送的所有事件",
},
},
rooms: {

View File

@@ -4,15 +4,18 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import { AppContext } from "./AppContext";
import storage from "./storage";
fetch("config.json")
.then(res => res.json())
.then(props =>
createRoot(document.getElementById("root")).render(
.then(props => {
storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
storage.setItem("support_url", props.supportURL);
return createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AppContext.Provider value={props}>
<App />
</AppContext.Provider>
</React.StrictMode>
)
);
});

View File

@@ -8,14 +8,17 @@ import { AppContext } from "../AppContext";
import englishMessages from "../i18n/en";
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
import { act } from "@testing-library/react";
describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
it("renders with no restriction to homeserver", async () => {
await act(async () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
});
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });

View File

@@ -88,7 +88,7 @@ const LoginPage = () => {
const notify = useNotify();
const { restrictBaseUrl } = useAppContext();
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
const allowMultipleBaseUrls = (Array.isArray(restrictBaseUrl) && restrictBaseUrl.length > 0 && restrictBaseUrl[0] !== "" && restrictBaseUrl[0] !== null);
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
@@ -222,7 +222,6 @@ const LoginPage = () => {
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -234,7 +233,6 @@ const LoginPage = () => {
autoComplete="current-password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -247,7 +245,6 @@ const LoginPage = () => {
disabled={loading}
readOnly={allowSingleBaseUrl}
resettable={allowAnyBaseUrl}
fullWidth
validate={[required(), validateBaseUrl]}
>
{allowMultipleBaseUrls &&
@@ -280,9 +277,9 @@ const LoginPage = () => {
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
fullWidth
value={locale}
onChange={e => setLocale(e.target.value)}
fullWidth
disabled={loading}
className="select"
>

View File

@@ -4,6 +4,7 @@ import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
import ErrorIcon from '@mui/icons-material/Error';
import {
Button,
Datagrid,
@@ -21,6 +22,7 @@ import {
Tab,
TabbedShowLayout,
TextField,
FunctionField,
TopToolbar,
useRecordContext,
useDelete,
@@ -35,13 +37,6 @@ import { get } from "lodash";
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const destinationRowSx = (record: RaRecord) => ({
backgroundColor: record.retry_last_ts > 0 ? "warning.light" : "primary.contrastText",
"& .MuiButtonBase-root": {
color: "primary.dark",
},
});
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
export const DestinationReconnectButton = () => {
@@ -105,7 +100,22 @@ const RetryDateField = (props: DateFieldProps) => {
return <DateField {...props} />;
};
const destinationFieldRender = (record: RaRecord) => {
if (record.retry_last_ts > 0) {
return (
<>
<ErrorIcon fontSize="inherit" color="error" sx={{verticalAlign: "middle"}}/>
{record.destination}
</>
);
}
return <> {record.destination} </>;
}
export const DestinationList = (props: ListProps) => {
const record = useRecordContext(props);
return (
<List
{...props}
@@ -113,8 +123,8 @@ export const DestinationList = (props: ListProps) => {
pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }}
>
<Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
<TextField source="destination" />
<Datagrid rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
<FunctionField source="destination" render={destinationFieldRender} />
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" />

View File

@@ -22,7 +22,7 @@ import {
} from "react-admin";
import { DATE_FORMAT } from "../components/date";
import { MXCField } from "../components/media";
import { ReportMediaContent } from "../components/media";
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
@@ -62,7 +62,7 @@ export const ReportShow = (props: ShowProps) => {
<TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" />
<TextField source="event_json.content.info.mimetype" />
<MXCField source="event_json.content.url" />
<ReportMediaContent source="event_json.content.url" />
<TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" />

View File

@@ -25,10 +25,10 @@ import {
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import AvatarField from "../components/AvatarField";
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
@@ -70,27 +70,25 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
() =>
const { mutate, isPending } = useMutation({
mutationFn: () =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
});
return (
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isLoading}>
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
<RoomDirectoryIcon />
</Button>
);
@@ -102,6 +100,10 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
if (!record) {
return null;
}
const handleSend = () => {
create(
"room_directory",
@@ -142,7 +144,6 @@ export const RoomDirectoryList = () => (
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar"
/>

View File

@@ -47,6 +47,7 @@ import {
} from "./room_directory";
import { DATE_FORMAT } from "../components/date";
import DeleteRoomButton from "../components/DeleteRoomButton";
import AvatarField from "../components/AvatarField";
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
@@ -67,6 +68,9 @@ const RoomTitle = () => {
const RoomShowActions = () => {
const record = useRecordContext();
if (!record) {
return null;
}
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
// FIXME: refresh after (un)publish
return (
@@ -87,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<AvatarField
source="avatar"
sx={{ height: "120px", width: "120px" }}
label="resources.rooms.fields.avatar"
/>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="topic" />

View File

@@ -1,4 +1,4 @@
import EqualizerIcon from "@mui/icons-material/Equalizer";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import {
Datagrid,
ExportButton,
@@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (
const resource: ResourceProps = {
name: "user_media_statistics",
icon: EqualizerIcon,
icon: PermMediaIcon,
list: UserMediaStatsList,
};

View File

@@ -36,7 +36,6 @@ import {
ResourceProps,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
maxLength,
regex,
@@ -52,13 +51,18 @@ import {
NumberField,
useListContext,
useNotify,
ToolbarClasses,
Identifier,
ToolbarClasses,
RaRecord,
ImageInput,
ImageField,
FunctionField,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField";
import DeleteUserButton from "../components/DeleteUserButton";
import { isASManaged } from "../components/mxid";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices";
@@ -87,11 +91,6 @@ const UserListActions = () => {
);
};
UserListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
};
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userFilters = [
@@ -101,26 +100,29 @@ const userFilters = [
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
];
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode, ownUserIsSelected: boolean }> = (props) => {
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean; asManagedUserIsSelected: boolean }> = props => {
const ownUserIsSelected = props.ownUserIsSelected;
const asManagedUserIsSelected = props.asManagedUserIsSelected;
const notify = useNotify();
const translate = useTranslate();
const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
if (ownUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>)
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
ev.stopPropagation();
} else if (asManagedUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
ev.stopPropagation();
}
};
return <div onClickCapture={handleDeleteClick}>
{props.children}
</div>
return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
};
const UserBulkActionButtons = () => {
const record = useListContext();
const [ ownUserIsSelected, setOwnUserIsSelected ] = useState(false);
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id");
const notify = useNotify();
@@ -128,23 +130,21 @@ const UserBulkActionButtons = () => {
useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId));
}, [ selectedIds ]);
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
}, [selectedIds]);
return <>
<ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BulkDeleteButton
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</UserPreventSelfDelete>
</>
};
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
return `/users/${id}`;
return (
<>
<ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteUserButton
selectedIds={selectedIds}
confirmTitle="resources.users.helper.erase"
confirmContent="resources.users.helper.erase_text"
/>
</UserPreventSelfDelete>
</>
);
};
export const UserList = (props: ListProps) => (
@@ -156,7 +156,10 @@ export const UserList = (props: ListProps) => (
actions={<UserListActions />}
pagination={<UserPagination />}
>
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
<Datagrid
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
bulkActionButtons={<UserBulkActionButtons />}
>
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
@@ -183,30 +186,33 @@ const UserEditActions = () => {
const translate = useTranslate();
const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<TopToolbar>
{!record?.deactivated && <ServerNoticeButton />}
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<DeleteButton
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
{record && record.id && (
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteUserButton
selectedIds={[record?.id]}
confirmTitle="resources.users.helper.erase"
confirmContent="resources.users.helper.erase_text"
/>
</UserPreventSelfDelete>
)}
</TopToolbar>
);
};
export const UserCreate = (props: CreateProps) => (
<Create { ...props} redirect={(resource, id, data) => {
return `users/${id}`;
}}>
<Create
{...props}
redirect={(resource: string | undefined, id: Identifier | undefined) => `${resource}/${id}`}
>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
@@ -231,13 +237,21 @@ export const UserCreate = (props: CreateProps) => (
const UserTitle = () => {
const record = useRecordContext();
if (!record) {
return null;
}
const translate = useTranslate();
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
if (isASManaged(record?.id)) {
username += " 🤖";
}
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? ( record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
{username}
</span>
);
};
@@ -246,48 +260,82 @@ const UserEditToolbar = () => {
const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return <>
<div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}>
return (
<>
<div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton />
</UserPreventSelfDelete>
</Toolbar>
</div>
</>
</Toolbar>
</div>
</>
);
};
const UserBooleanInput = (props) => {
const UserBooleanInput = props => {
const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id");
const isOwnUser = false;
let ownUserIsSelected = false;
if (record && (record.id === ownUserId)) {
ownUserIsSelected = true;
let asManagedUserIsSelected = false;
if (record) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}><BooleanInput {...props} disabled={ownUserIsSelected} /></UserPreventSelfDelete>
}
return (
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
</UserPreventSelfDelete>
);
};
const UserPasswordInput = props => {
const record = useRecordContext();
let asManagedUserIsSelected = false;
if (record) {
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error" disabled={asManagedUserIsSelected} />
);
};
export const UserEdit = (props: EditProps) => {
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
<TextInput source="id" disabled />
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
<ImageInput
source="avatar_file"
label="resources.users.fields.avatar"
accept={{ "image/*": [".png", ".jpg"] }}
>
<ImageField source="src" title="Avatar" sx={{ '& img': {
width: "120px !important",
height: "120px !important",
objectFit: "cover !important",
borderRadius: '50% !important',
}}} />
</ImageInput>
<TextInput source="id" readOnly />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<BooleanInput source="locked" />
<UserBooleanInput source="locked" />
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
<BooleanInput source="erased" disabled />
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
@@ -314,7 +362,7 @@ export const UserEdit = (props: EditProps) => {
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
<ReferenceManyField reference="devices" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
@@ -354,8 +402,8 @@ export const UserEdit = (props: EditProps) => {
<DateField source="created_ts" showTime options={DATE_FORMAT} />
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
<NumberField source="media_length" />
<TextField source="media_type" />
<TextField source="upload_name" />
<TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
<FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
<TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />

View File

@@ -101,7 +101,7 @@ describe("authProvider", () => {
});
it("should reject if error.status is 401", async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
});
it("should reject if error.status is 403", async () => {

View File

@@ -96,8 +96,13 @@ const authProvider: AuthProvider = {
};
if (typeof access_token === "string") {
await fetchUtils.fetchJson(logout_api_url, options);
storage.removeItem("access_token");
try {
await fetchUtils.fetchJson(logout_api_url, options);
} catch (err) {
console.log("Error logging out", err);
} finally {
storage.removeItem("access_token");
}
}
},
// called when the API returns an error

View File

@@ -18,7 +18,7 @@ describe("dataProvider", () => {
JSON.stringify({
users: [
{
name: "user_id1",
name: "@user_id1:provider",
password_hash: "password_hash1",
is_guest: 0,
admin: 0,
@@ -27,7 +27,7 @@ describe("dataProvider", () => {
displayname: "User One",
},
{
name: "user_id2",
name: "@user_id2:provider",
password_hash: "password_hash2",
is_guest: 0,
admin: 1,
@@ -47,7 +47,7 @@ describe("dataProvider", () => {
filter: { author_id: 12 },
});
expect(users.data[0].id).toEqual("user_id1");
expect(users.data[0].id).toEqual("@user_id1:provider");
expect(users.total).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1);
});
@@ -55,7 +55,7 @@ describe("dataProvider", () => {
it("fetches one user", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({
name: "user_id1",
name: "@user_id1:provider",
password: "user_password",
displayname: "User",
threepids: [
@@ -74,9 +74,9 @@ describe("dataProvider", () => {
})
);
const user = await dataProvider.getOne("users", { id: "user_id1" });
const user = await dataProvider.getOne("users", { id: "@user_id1:provider" });
expect(user.data.id).toEqual("user_id1");
expect(user.data.id).toEqual("@user_id1:provider");
expect(user.data.displayname).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1);
});

View File

@@ -1,15 +1,27 @@
import { stringify } from "query-string";
import { DataProvider, DeleteParams, HttpError, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
import {
DataProvider,
DeleteParams,
DeleteManyParams,
HttpError,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
UpdateParams,
fetchUtils,
withLifecycleCallbacks,
} from "react-admin";
import storage from "../storage";
import { returnMXID } from "./synapse";
import { MatrixError, displayError } from "../components/error";
// Adds the access token to all requests
const jsonClient = async (url: string, options: Options = {}) => {
const token = storage.getItem("access_token");
console.log("httpClient " + url);
if (token != null) {
if (token !== null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
@@ -22,23 +34,24 @@ const jsonClient = async (url: string, options: Options = {}) => {
const error = err as HttpError;
const errorStatus = error.status;
const errorBody = error.body as MatrixError;
const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message);
const errMsg = !!errorBody?.errcode
? displayError(errorBody.errcode, errorStatus, errorBody.error)
: displayError("M_INVALID", errorStatus, error.message);
return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
}
};
const mxcUrlToHttp = (mxcUrl: string) => {
const homeserver = storage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) return null;
const serverName = ret[1];
const mediaId = ret[2];
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
const filterUndefined = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
};
interface Action {
endpoint: string;
method?: string;
body?: Record<string, any>;
}
interface Room {
room_id: string;
name?: string;
@@ -225,17 +238,28 @@ export interface DeleteMediaResult {
total: number;
}
export interface UploadMediaParams {
file: File;
filename: string;
content_type: string;
}
export interface UploadMediaResult {
content_uri: string;
}
export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
}
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
map: (u: User) => ({
map: async (u: User) => ({
...u,
id: u.name,
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
id: returnMXID(u.name),
avatar_src: u.avatar_url ? u.avatar_url : undefined,
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
@@ -245,12 +269,12 @@ const resourceMap = {
data: "users",
total: json => json.total,
create: (data: RaRecord) => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`,
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(returnMXID(data.id))}`,
body: data,
method: "PUT",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`,
body: { erase: true },
method: "POST",
}),
@@ -349,7 +373,7 @@ const resourceMap = {
id: um.media_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`,
}),
data: "media",
total: json => json.total,
@@ -384,7 +408,7 @@ const resourceMap = {
create: (data: RaServerNotice) => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
user_id: returnMXID(data.id),
content: {
msgtype: "m.text",
body: data.body,
@@ -397,7 +421,7 @@ const resourceMap = {
path: "/_synapse/admin/v1/statistics/users/media",
map: (usms: UserMediaStatistic) => ({
...usms,
id: usms.user_id,
id: returnMXID(usms.user_id),
}),
data: "users",
total: json => json.total,
@@ -423,7 +447,7 @@ const resourceMap = {
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
avatar_src: rd.avatar_url ? rd.avatar_url : undefined,
}),
data: "chunk",
total: json => json.total_room_count_estimate,
@@ -499,12 +523,12 @@ function getSearchOrder(order: "ASC" | "DESC") {
}
}
const dataProvider: SynapseDataProvider = {
const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const { page, perPage } = params.pagination as PaginationPayload;
const { field, order } = params.sort as SortPayload;
const from = (page - 1) * perPage;
const query = {
from: from,
@@ -526,11 +550,13 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url);
const formattedData = await json[res.data].map(res.map);
return {
data: json[res.data].map(res.map),
data: formattedData,
total: res.total(json, from, perPage),
};
},
@@ -580,7 +606,7 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(endpoint_url);
return {
@@ -686,7 +712,7 @@ const dataProvider: SynapseDataProvider = {
},
deleteMany: async (resource, params) => {
console.log("deleteMany " + resource);
console.log("deleteMany " + resource, "params", params);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
@@ -703,6 +729,7 @@ const dataProvider: SynapseDataProvider = {
});
})
);
return {
data: responses.map(({ json }) => json),
};
@@ -741,6 +768,79 @@ const dataProvider: SynapseDataProvider = {
const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult;
},
uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
const base_url = storage.getItem("base_url");
const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
const { json } = await jsonClient(`${uploadMediaURL}?filename=${filename}`, {
method: "POST",
body: file,
headers: new Headers({
Accept: "application/json",
"Content-Type": content_type,
}) as Headers,
});
return json as UploadMediaResult;
},
};
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
{
resource: "users",
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
const avatarFile = params.data.avatar_file?.rawFile;
const avatarErase = params.data.avatar_erase;
if (avatarErase) {
params.data.avatar_url = "";
return params;
}
if (avatarFile instanceof File) {
const reponse = await dataProvider.uploadMedia({
file: avatarFile,
filename: params.data.avatar_file.title,
content_type: params.data.avatar_file.rawFile.type,
});
params.data.avatar_url = reponse.content_uri;
}
return params;
},
beforeDelete: async (params: DeleteParams<any>, dataProvider: DataProvider) => {
if (params.meta?.deleteMedia) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(params.id))}/media`;
await jsonClient(endpoint_url, { method: "DELETE" });
}
if (params.meta?.redactEvents) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(params.id))}/redact`;
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
}
return params;
},
beforeDeleteMany: async (params: DeleteManyParams<any>, dataProvider: DataProvider) => {
await Promise.all(
params.ids.map(async id => {
if (params.meta?.deleteMedia) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`;
await jsonClient(endpoint_url, { method: "DELETE" });
}
if (params.meta?.redactEvents) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(id))}/redact`;
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
}
})
);
return params;
},
},
]);
export default dataProvider;

View File

@@ -1,4 +1,4 @@
import { fetchUtils } from "react-admin";
import { Identifier, fetchUtils } from "react-admin";
import storage from "../storage";
@@ -54,11 +54,6 @@ export const getSupportedLoginFlows = async baseUrl => {
return response.json.flows;
};
export const getMediaUrl = media_id => {
const baseUrl = storage.getItem("base_url");
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
};
/**
* Generate a random MXID for current homeserver
* @returns full MXID as string
@@ -72,6 +67,26 @@ export function generateRandomMxId(): string {
return `@${localpart}:${homeserver}`;
}
/**
* Return the full MXID from an arbitrary input
* @param input the input string
* @returns full MXID as string
*/
export function returnMXID(input: string | Identifier): string {
const homeserver = storage.getItem("home_server");
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
const mxidPattern = /^@[^@:]+:[^@:]+$/;
if (typeof input === 'string' && mxidPattern.test(input)) {
return input; // Already a valid MXID
}
// If input is not a valid MXID, assume it's a localpart and construct the MXID
const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input;
return `@${localpart}:${homeserver}`;
}
/**
* Generate a random user password
* @returns a new random password as string

43
src/utils/fetchMedia.ts Normal file
View File

@@ -0,0 +1,43 @@
import storage from "../storage";
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) {
throw new Error("Invalid mxcUrl");
}
const serverName = ret[1];
const mediaId = ret[2];
return { serverName, mediaId };
};
export type MediaType = "thumbnail" | "original";
export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise<Response> => {
const homeserver = storage.getItem("base_url");
const accessToken = storage.getItem("access_token");
const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
if (!serverName || !mediaId) {
throw new Error("Invalid mxcUrl");
}
let url = "";
if (type === "thumbnail") {
// ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
} else if (type === "original") {
url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
} else {
throw new Error("Invalid authenticated media type");
}
const response = await fetch(`${url}`, {
headers: {
authorization: `Bearer ${accessToken}`,
},
});
return response;
};

191
testdata/synapse/homeserver.yaml vendored Normal file
View File

@@ -0,0 +1,191 @@
account_threepid_delegates:
msisdn: ''
alias_creation_rules:
- action: allow
alias: '*'
room_id: '*'
user_id: '*'
allow_guest_access: false
allow_public_rooms_over_federation: true
allow_public_rooms_without_auth: true
app_service_config_files: []
autocreate_auto_join_rooms: true
background_updates: null
caches:
global_factor: 0.5
per_cache_factors: null
cas_config: null
database:
args:
cp_max: 10
cp_min: 5
database: synapse
host: postgres
password: synapse
port: 5432
user: synapse
name: psycopg2
txn_limit: 0
default_room_version: '10'
disable_msisdn_registration: true
email:
enable_media_repo: true
enable_metrics: false
enable_registration: false
enable_registration_captcha: false
enable_registration_without_verification: false
enable_room_list_search: true
encryption_enabled_by_default_for_room_type: 'off'
event_cache_size: 100K
federation_rr_transactions_per_room_per_second: 50
form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi
include_profile_data_on_invite: true
instance_map: {}
limit_profile_requests_to_users_who_share_rooms: false
limit_remote_rooms: null
listeners:
- bind_addresses:
- '::'
port: 8008
resources:
- compress: false
names:
- client
tls: false
type: http
x_forwarded: true
log_config: /config/synapse.log.config
macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE
manhole_settings: null
max_spider_size: 10M
max_upload_size: 1024M
media_retention:
local_media_lifetime: 30d
remote_media_lifetime: 7d
media_storage_providers: []
media_store_path: /media-store
metrics_flags: null
modules: []
oembed: null
oidc_providers: null
old_signing_keys: null
opentracing: null
password_config:
enabled: true
localdb_enabled: true
pepper: zfvnYqxe3GTkdJ9BlfZiAqy2zMsjOg02uBTEiWLp2hjQGqlDw33pTSTplE6HoWlF
policy: null
pid_file: /homeserver.pid
presence:
enabled: true
public_baseurl: http://synapse:8008/
push:
include_content: true
rc_admin_redaction:
burst_count: 50
per_second: 1
rc_federation:
concurrent: 3
reject_limit: 50
sleep_delay: 500
sleep_limit: 10
window_size: 1000
rc_invites:
per_issuer:
burst_count: 10
per_second: 0.3
per_room:
burst_count: 10
per_second: 0.3
per_user:
burst_count: 5
per_second: 0.003
rc_joins:
local:
burst_count: 10
per_second: 0.1
remote:
burst_count: 10
per_second: 0.01
rc_login:
account:
burst_count: 3
per_second: 0.17
address:
burst_count: 3
per_second: 0.17
failed_attempts:
burst_count: 3
per_second: 0.17
rc_message:
burst_count: 10
per_second: 0.2
rc_registration:
burst_count: 3
per_second: 0.17
recaptcha_private_key: ''
recaptcha_public_key: ''
redaction_retention_period: 5m
redis:
enabled: false
host: null
password: null
port: 6379
registration_requires_token: false
registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9
report_stats: false
require_auth_for_profile_requests: false
retention:
enabled: true
purge_jobs:
- interval: 12h
room_list_publication_rules:
- action: allow
alias: '*'
room_id: '*'
user_id: '*'
room_prejoin_state: null
saml2_config:
sp_config: null
user_mapping_provider:
config: null
server_name: synapse
signing_key_path: /config/synapse.signing.key
spam_checker: []
sso: null
stats: null
stream_writers: {}
templates: null
tls_certificate_path: null
tls_private_key_path: null
trusted_key_servers:
- server_name: matrix.org
turn_allow_guests: false
ui_auth: null
url_preview_accept_language:
- en-US
- en
url_preview_enabled: true
url_preview_ip_range_blacklist:
- 127.0.0.0/8
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 100.64.0.0/10
- 192.0.0.0/24
- 169.254.0.0/16
- 192.88.99.0/24
- 198.18.0.0/15
- 192.0.2.0/24
- 198.51.100.0/24
- 203.0.113.0/24
- 224.0.0.0/4
- ::1/128
- fe80::/10
- fc00::/7
- 2001:db8::/32
- ff00::/8
- fec0::/10
user_directory: null
user_ips_max_age: 5m

28
testdata/synapse/synapse.log.config vendored Normal file
View File

@@ -0,0 +1,28 @@
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
filters:
context:
(): synapse.util.logcontext.LoggingContextFilter
request: ""
handlers:
console:
class: logging.StreamHandler
formatter: precise
filters: [context]
loggers:
synapse:
level: INFO
shared_secret_authenticator:
level: INFO
rest_auth_provider:
level: INFO
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
root:
level: INFO
handlers: [console]

1
testdata/synapse/synapse.signing.key vendored Normal file
View File

@@ -0,0 +1 @@
ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo

View File

@@ -24,7 +24,7 @@
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* Enable strict null checks. */
"strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */

14257
yarn.lock

File diff suppressed because it is too large Load Diff