Compare commits
16 Commits
v0.10.3-et
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
31d3712dbb | ||
![]() |
a15dad4a31 | ||
![]() |
9062a6afb1 | ||
![]() |
a79c3597d6 | ||
![]() |
470f1b5455 | ||
![]() |
46cc0e2fda | ||
![]() |
f3080e9468 | ||
![]() |
f8fe1166e2 | ||
![]() |
02bee92806 | ||
![]() |
b7f8e03894 | ||
![]() |
59bb4b4183 | ||
![]() |
bb53d53692 | ||
![]() |
52a2f1c936 | ||
![]() |
e328380c77 | ||
![]() |
a277ded227 | ||
![]() |
48d933e028 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -5,6 +5,6 @@
|
|||||||
},
|
},
|
||||||
"eslint.nodePath": ".yarn/sdks",
|
"eslint.nodePath": ".yarn/sdks",
|
||||||
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
||||||
"typescript.tsdk": ".yarn/sdks/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
}
|
}
|
||||||
|
5
.watchmanconfig
Normal file
5
.watchmanconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"ignore_dirs": [
|
||||||
|
"testdata"
|
||||||
|
]
|
||||||
|
}
|
@@ -1 +0,0 @@
|
|||||||
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
|
216
README.md
216
README.md
@@ -1,7 +1,32 @@
|
|||||||
# Synapse Admin UI [](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
# Synapse Admin UI [](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
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
|
## Fork differences
|
||||||
|
|
||||||
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
|
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
|
||||||
@@ -32,6 +57,12 @@ The following changes are already implemented:
|
|||||||
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
|
* [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)
|
* [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)
|
* [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_
|
_the list will be updated as new changes are added_
|
||||||
|
|
||||||
@@ -46,85 +77,21 @@ After that open `http://localhost:5173` in your browser, login using the followi
|
|||||||
* Password: admin
|
* Password: admin
|
||||||
* Homeserver URL: http://localhost:8008
|
* Homeserver URL: http://localhost:8008
|
||||||
|
|
||||||
## Usage
|
## Configuration
|
||||||
|
|
||||||
### Supported Synapse
|
You can use `config.json` file to configure synapse-admin
|
||||||
|
|
||||||
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected!
|
The `config.json` can be injected into a Docker container using a bind mount.
|
||||||
|
|
||||||
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
|
||||||
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
|
||||||
|
|
||||||
After entering the URL on the login page of synapse-admin the server version appears below the input field.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
You need access to the following endpoints:
|
|
||||||
|
|
||||||
- `/_matrix`
|
|
||||||
- `/_synapse/admin`
|
|
||||||
|
|
||||||
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
|
|
||||||
|
|
||||||
### Use without install
|
|
||||||
|
|
||||||
You can use the current version of Synapse Admin without own installation direct
|
|
||||||
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
|
|
||||||
|
|
||||||
**Note:**
|
|
||||||
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
|
||||||
**Remember: You have no need to expose these endpoints to the internet but to your network.**
|
|
||||||
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
|
|
||||||
|
|
||||||
### Step-By-Step install
|
|
||||||
|
|
||||||
You have three options:
|
|
||||||
|
|
||||||
1. [Download the tarball and serve with any webserver](#steps-for-1)
|
|
||||||
2. [Download the source code from github and run using nodejs](#steps-for-2)
|
|
||||||
3. [Run the Docker container](#steps-for-3)
|
|
||||||
|
|
||||||
#### Steps for 1)
|
|
||||||
|
|
||||||
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
|
||||||
- configure a vhost for synapse admin on your webserver
|
|
||||||
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest
|
|
||||||
- unpack the .tar.gz
|
|
||||||
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
|
|
||||||
- open the url of the vhost in your browser
|
|
||||||
|
|
||||||
#### Steps for 2)
|
|
||||||
|
|
||||||
- make sure you have installed the following: git, yarn, nodejs
|
|
||||||
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
|
|
||||||
- change into downloaded directory: `cd synapse-admin`
|
|
||||||
- download dependencies: `yarn install`
|
|
||||||
- start web server: `yarn start`
|
|
||||||
|
|
||||||
#### 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
|
```yml
|
||||||
services:
|
services:
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
container_name: synapse-admin
|
...
|
||||||
hostname: synapse-admin
|
volumes:
|
||||||
build:
|
./config.json:/app/config.json:ro
|
||||||
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
|
|
||||||
|
|
||||||
### Restricting available homeserver
|
### Restricting available homeserver
|
||||||
|
|
||||||
You can restrict the homeserver(s), so that the user can no longer define it himself.
|
You can restrict the homeserver(s), so that the user can no longer define it himself.
|
||||||
@@ -145,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
|
||||||
|
|
||||||
|
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
|
```yml
|
||||||
services:
|
services:
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
...
|
container_name: synapse-admin
|
||||||
volumes:
|
hostname: synapse-admin
|
||||||
./config.json:/app/config.json:ro
|
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
|
### Serving Synapse-Admin on a different path
|
||||||
|
|
||||||
The path prefix where synapse-admin is served can only be changed during the build step.
|
The path prefix where synapse-admin is served can only be changed during the build step.
|
||||||
@@ -182,7 +240,7 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
image: awesometechnologies/synapse-admin:latest
|
image: etkecc/synapse-admin:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
@@ -193,10 +251,6 @@ services:
|
|||||||
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
||||||
|
34
package.json
34
package.json
@@ -10,21 +10,20 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/etkecc/synapse-admin"
|
"url": "https://github.com/etkecc/synapse-admin"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.1.1",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.7.0",
|
"@eslint/js": "^9.7.0",
|
||||||
"@testing-library/dom": "^10.0.0",
|
"@testing-library/dom": "^10.0.0",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.14",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||||
"@typescript-eslint/parser": "^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": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
@@ -37,11 +36,11 @@
|
|||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"ts-jest": "^29.2.3",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"typescript-eslint": "^7.16.1",
|
"typescript-eslint": "^7.16.1",
|
||||||
"vite": "^5.3.4",
|
"vite": "^5.4.6",
|
||||||
"vite-plugin-version-mark": "^0.1.0"
|
"vite-plugin-version-mark": "^0.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -49,27 +48,26 @@
|
|||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@haleos/ra-language-german": "^1.0.0",
|
"@haleos/ra-language-german": "^1.0.0",
|
||||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||||
"@mui/icons-material": "^5.16.4",
|
"@mui/icons-material": "^6.1.1",
|
||||||
"@mui/material": "^5.16.4",
|
"@mui/material": "^6.1.1",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"query-string": "^7.1.3",
|
"ra-core": "^5.2.0",
|
||||||
"ra-core": "^4.16.20",
|
"ra-i18n-polyglot": "^5.2.0",
|
||||||
"ra-i18n-polyglot": "^4.16.20",
|
"ra-language-english": "^5.2.0",
|
||||||
"ra-language-english": "^4.16.20",
|
"ra-language-farsi": "^5.0.0",
|
||||||
"ra-language-farsi": "^4.2.0",
|
"ra-language-french": "^5.2.0",
|
||||||
"ra-language-french": "^4.16.20",
|
|
||||||
"ra-language-italian": "^3.13.1",
|
"ra-language-italian": "^3.13.1",
|
||||||
"ra-language-russian": "^4.14.2",
|
"ra-language-russian": "^4.14.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-admin": "^4.16.20",
|
"react-admin": "^5.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.52.1",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-query": "^3.39.3",
|
"react-router": "^6.26.2",
|
||||||
"react-router": "^6.25.1",
|
"react-router-dom": "^6.26.2"
|
||||||
"react-router-dom": "^6.25.1"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite serve",
|
"start": "vite serve",
|
||||||
|
@@ -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";
|
import App from "./App";
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
|
|||||||
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AdminLayout } from "./components/AdminLayout";
|
||||||
import { ImportFeature } from "./components/ImportFeature";
|
import { ImportFeature } from "./components/ImportFeature";
|
||||||
import germanMessages from "./i18n/de";
|
import germanMessages from "./i18n/de";
|
||||||
import englishMessages from "./i18n/en";
|
import englishMessages from "./i18n/en";
|
||||||
@@ -21,6 +22,7 @@ import userMediaStats from "./resources/user_media_statistics";
|
|||||||
import users from "./resources/users";
|
import users from "./resources/users";
|
||||||
import authProvider from "./synapse/authProvider";
|
import authProvider from "./synapse/authProvider";
|
||||||
import dataProvider from "./synapse/dataProvider";
|
import dataProvider from "./synapse/dataProvider";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// TODO: Can we use lazy loading together with browser locale?
|
// TODO: Can we use lazy loading together with browser locale?
|
||||||
const messages = {
|
const messages = {
|
||||||
@@ -45,15 +47,18 @@ const i18nProvider = polyglotI18nProvider(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<Admin
|
<Admin
|
||||||
disableTelemetry
|
disableTelemetry
|
||||||
requireAuth
|
requireAuth
|
||||||
|
layout={AdminLayout}
|
||||||
loginPage={LoginPage}
|
loginPage={LoginPage}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
darkTheme={{ palette: { mode: "dark" } }}
|
|
||||||
>
|
>
|
||||||
<CustomRoutes>
|
<CustomRoutes>
|
||||||
<Route path="/import_users" element={<ImportFeature />} />
|
<Route path="/import_users" element={<ImportFeature />} />
|
||||||
@@ -76,6 +81,7 @@ const App = () => (
|
|||||||
<Resource name="room_state" />
|
<Resource name="room_state" />
|
||||||
<Resource name="destination_rooms" />
|
<Resource name="destination_rooms" />
|
||||||
</Admin>
|
</Admin>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@@ -2,6 +2,8 @@ import { createContext, useContext } from "react";
|
|||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
restrictBaseUrl: string | string[];
|
restrictBaseUrl: string | string[];
|
||||||
|
asManagedUsers: string[];
|
||||||
|
supportURL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppContext = createContext({});
|
export const AppContext = createContext({});
|
||||||
|
26
src/components/AdminLayout.tsx
Normal file
26
src/components/AdminLayout.tsx
Normal 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>
|
||||||
|
);
|
@@ -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 { RecordContextProvider } from "react-admin";
|
||||||
|
import { act } from "react";
|
||||||
import AvatarField from "./AvatarField";
|
import AvatarField from "./AvatarField";
|
||||||
|
|
||||||
describe("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 = {
|
const value = {
|
||||||
avatar: "foo",
|
avatar: "mxc://serverName/mediaId",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
render(
|
render(
|
||||||
<RecordContextProvider value={value}>
|
<RecordContextProvider value={value}>
|
||||||
<AvatarField source="avatar" />
|
<AvatarField source="avatar" />
|
||||||
</RecordContextProvider>
|
</RecordContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const img = screen.getByRole("img");
|
||||||
|
expect(img.getAttribute("src")).toBe("mock-object-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,12 +1,37 @@
|
|||||||
import { get } from "lodash";
|
import { get } from "lodash";
|
||||||
|
import { Avatar, AvatarProps } from "@mui/material";
|
||||||
import { Avatar } from "@mui/material";
|
|
||||||
import { useRecordContext } from "react-admin";
|
import { useRecordContext } from "react-admin";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
const AvatarField = ({ source, ...rest }) => {
|
const AvatarField = ({ source, ...rest }: AvatarProps & { source: string, label?: string }) => {
|
||||||
const record = useRecordContext(rest);
|
|
||||||
const src = get(record, source)?.toString();
|
|
||||||
const { alt, classes, sizes, sx, variant } = rest;
|
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} />;
|
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -16,7 +16,7 @@ const resourceName = "rooms";
|
|||||||
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [block, setBlock] = useState(true);
|
const [block, setBlock] = useState(false);
|
||||||
|
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const redirect = useRedirect();
|
const redirect = useRedirect();
|
||||||
@@ -74,12 +74,11 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
|||||||
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||||
<SimpleForm toolbar={false}>
|
<SimpleForm toolbar={false}>
|
||||||
<BooleanInput
|
<BooleanInput
|
||||||
fullWidth
|
|
||||||
source="block"
|
source="block"
|
||||||
value={block}
|
value={block}
|
||||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
|
||||||
label="resources.rooms.action.erase.fields.block"
|
label="resources.rooms.action.erase.fields.block"
|
||||||
defaultValue={true}
|
defaultValue={false}
|
||||||
/>
|
/>
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
117
src/components/DeleteUserButton.tsx
Normal file
117
src/components/DeleteUserButton.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
|
||||||
|
import ActionDelete from "@mui/icons-material/Delete";
|
||||||
|
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||||
|
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||||
|
|
||||||
|
interface DeleteUserButtonProps {
|
||||||
|
selectedIds: Identifier[];
|
||||||
|
confirmTitle: string;
|
||||||
|
confirmContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName = "users";
|
||||||
|
|
||||||
|
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [deleteMedia, setDeleteMedia] = useState(false);
|
||||||
|
const [redactEvents, setRedactEvents] = useState(false);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
|
||||||
|
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||||
|
const unselectAll = useUnselectAll(resourceName);
|
||||||
|
const recordIds = props.selectedIds;
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
|
||||||
|
deleteMany(
|
||||||
|
resourceName,
|
||||||
|
{ ids: recordIds, meta: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("ra.notification.deleted", {
|
||||||
|
messageArgs: {
|
||||||
|
smart_count: recordIds.length,
|
||||||
|
},
|
||||||
|
type: 'info' as NotificationType,
|
||||||
|
});
|
||||||
|
handleDialogClose();
|
||||||
|
unselectAll();
|
||||||
|
redirect("/users");
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={"ra-delete-button"}
|
||||||
|
key="button"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"&.MuiButton-sizeSmall": {
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
color={"error"}
|
||||||
|
startIcon={<ActionDelete />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.delete")}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onClose={handleDialogClose}>
|
||||||
|
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||||
|
<SimpleForm toolbar={false}>
|
||||||
|
<BooleanInput
|
||||||
|
source="deleteMedia"
|
||||||
|
value={deleteMedia}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
|
||||||
|
label="resources.users.action.delete_media"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
source="redactEvents"
|
||||||
|
value={redactEvents}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
|
||||||
|
label="resources.users.action.redact_events"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||||
|
{translate("ra.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={false}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||||
|
autoFocus
|
||||||
|
startIcon={<ActionCheck />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteUserButton;
|
@@ -20,7 +20,7 @@ import {
|
|||||||
useTranslate,
|
useTranslate,
|
||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -43,7 +43,6 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
source="body"
|
source="body"
|
||||||
label="resources.servernotices.fields.body"
|
label="resources.servernotices.fields.body"
|
||||||
fullWidth
|
|
||||||
multiline
|
multiline
|
||||||
rows="4"
|
rows="4"
|
||||||
resettable
|
resettable
|
||||||
@@ -64,6 +63,10 @@ export const ServerNoticeButton = () => {
|
|||||||
const handleDialogOpen = () => setOpen(true);
|
const handleDialogOpen = () => setOpen(true);
|
||||||
const handleDialogClose = () => setOpen(false);
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = (values: Partial<RaRecord>) => {
|
const handleSend = (values: Partial<RaRecord>) => {
|
||||||
create(
|
create(
|
||||||
"servernotices",
|
"servernotices",
|
||||||
@@ -100,13 +103,12 @@ export const ServerNoticeBulkButton = () => {
|
|||||||
const unselectAllUsers = useUnselectAll("users");
|
const unselectAllUsers = useUnselectAll("users");
|
||||||
const dataProvider = useDataProvider();
|
const dataProvider = useDataProvider();
|
||||||
|
|
||||||
const { mutate: sendNotices, isLoading } = useMutation(
|
const { mutate: sendNotices, isPending } = useMutation({
|
||||||
data =>
|
mutationFn: (data) =>
|
||||||
dataProvider.createMany("servernotices", {
|
dataProvider.createMany("servernotices", {
|
||||||
ids: selectedIds,
|
ids: selectedIds,
|
||||||
data: data,
|
data: data,
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notify("resources.servernotices.action.send_success");
|
notify("resources.servernotices.action.send_success");
|
||||||
unselectAllUsers();
|
unselectAllUsers();
|
||||||
@@ -116,12 +118,11 @@ export const ServerNoticeBulkButton = () => {
|
|||||||
notify("resources.servernotices.action.send_failure", {
|
notify("resources.servernotices.action.send_failure", {
|
||||||
type: "error",
|
type: "error",
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
|
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
|
||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
|
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
|
||||||
|
@@ -1,9 +1,15 @@
|
|||||||
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
||||||
|
import { isASManaged } from "./mxid";
|
||||||
|
|
||||||
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
|
|
||||||
|
let isASManagedUser = false;
|
||||||
|
if (record.user_id) {
|
||||||
|
isASManagedUser = isASManaged(record.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeleteWithConfirmButton
|
<DeleteWithConfirmButton
|
||||||
{...props}
|
{...props}
|
||||||
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
|||||||
confirmContent="resources.devices.action.erase.content"
|
confirmContent="resources.devices.action.erase.content"
|
||||||
mutationMode="pessimistic"
|
mutationMode="pessimistic"
|
||||||
redirect={false}
|
redirect={false}
|
||||||
|
disabled={isASManagedUser}
|
||||||
translateOptions={{
|
translateOptions={{
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.display_name ? record.display_name : record.id,
|
name: record.display_name ? record.display_name : record.id,
|
||||||
|
@@ -8,7 +8,9 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
|||||||
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
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 { alpha, useTheme } from "@mui/material/styles";
|
||||||
import {
|
import {
|
||||||
BooleanInput,
|
BooleanInput,
|
||||||
@@ -28,13 +30,12 @@ import {
|
|||||||
useRefresh,
|
useRefresh,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { dateParser } from "./date";
|
import { dateParser } from "./date";
|
||||||
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
import { getMediaUrl } from "../synapse/synapse";
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -55,14 +56,12 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
fullWidth
|
|
||||||
source="before_ts"
|
source="before_ts"
|
||||||
label="delete_media.fields.before_ts"
|
label="delete_media.fields.before_ts"
|
||||||
defaultValue={0}
|
defaultValue={0}
|
||||||
parse={dateParser}
|
parse={dateParser}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
fullWidth
|
|
||||||
source="size_gt"
|
source="size_gt"
|
||||||
label="delete_media.fields.size_gt"
|
label="delete_media.fields.size_gt"
|
||||||
defaultValue={0}
|
defaultValue={0}
|
||||||
@@ -70,7 +69,6 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
step={1024}
|
step={1024}
|
||||||
/>
|
/>
|
||||||
<BooleanInput
|
<BooleanInput
|
||||||
fullWidth
|
|
||||||
source="keep_profiles"
|
source="keep_profiles"
|
||||||
label="delete_media.fields.keep_profiles"
|
label="delete_media.fields.keep_profiles"
|
||||||
defaultValue={true}
|
defaultValue={true}
|
||||||
@@ -86,9 +84,8 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||||
const { mutate: deleteMedia, isLoading } = useMutation(
|
const { mutate: deleteMedia, isPending } = useMutation({
|
||||||
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||||
{
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notify("delete_media.action.send_success");
|
notify("delete_media.action.send_success");
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@@ -98,8 +95,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const openDialog = () => setOpen(true);
|
const openDialog = () => setOpen(true);
|
||||||
const closeDialog = () => setOpen(false);
|
const closeDialog = () => setOpen(false);
|
||||||
@@ -110,7 +106,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
label="delete_media.action.send"
|
label="delete_media.action.send"
|
||||||
onClick={openDialog}
|
onClick={openDialog}
|
||||||
disabled={isLoading}
|
disabled={isPending}
|
||||||
sx={{
|
sx={{
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
"&:hover": {
|
"&: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 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 (
|
return (
|
||||||
<Box style={{ whiteSpace: "pre" }}>
|
<>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
<Tooltip title={translate("resources.users_media.action.open")}>
|
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||||
<span>
|
<span>
|
||||||
|
{isImage && (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
disabled={loading}
|
||||||
to={url}
|
onClick={() => handleFile(true)}
|
||||||
target="_blank"
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
rel="noopener"
|
|
||||||
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
|
|
||||||
>
|
>
|
||||||
<FileOpenIcon />
|
{loading ? <DownloadingIcon /> : <FileOpenIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{label}
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleFile(false)}
|
||||||
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||||
|
</Button>
|
||||||
|
<span>{label}</span>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MediaIDField = ({ source }) => {
|
export const MediaIDField = ({ source }) => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const homeserver = storage.getItem("home_server");
|
const homeserver = storage.getItem("home_server");
|
||||||
const record = useRecordContext();
|
|
||||||
if (!record) return null;
|
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mediaID = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
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();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mxcURL = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
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
16
src/components/mxid.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const de: SynapseTranslationMessages = {
|
const de: SynapseTranslationMessages = {
|
||||||
...formalGermanMessages,
|
...formalGermanMessages,
|
||||||
|
ra: {
|
||||||
|
...formalGermanMessages.ra,
|
||||||
|
navigation: {
|
||||||
|
...formalGermanMessages.ra.navigation,
|
||||||
|
no_filtered_results: "Keine Ergebnisse",
|
||||||
|
clear_filters: "Alle Filter entfernen",
|
||||||
|
},
|
||||||
|
},
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Heimserver URL",
|
base_url: "Heimserver URL",
|
||||||
@@ -142,12 +150,16 @@ const de: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
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.",
|
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
erase: "DSGVO konformes Löschen der Benutzerdaten.",
|
||||||
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt",
|
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: {
|
action: {
|
||||||
erase: "Lösche Benutzerdaten",
|
erase: "Lösche Benutzerdaten",
|
||||||
erase_avatar: "Avatar löschen"
|
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: {
|
rooms: {
|
||||||
|
@@ -142,11 +142,15 @@ const en: SynapseTranslationMessages = {
|
|||||||
password: "Changing password will log user out of all sessions.",
|
password: "Changing password will log user out of all sessions.",
|
||||||
deactivate: "You must provide a password to re-activate an account.",
|
deactivate: "You must provide a password to re-activate an account.",
|
||||||
erase: "Mark the user as GDPR-erased",
|
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.",
|
erase_admin_error: "Deleting own user is not allowed.",
|
||||||
|
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Erase user data",
|
erase: "Erase user data",
|
||||||
erase_avatar: "Erase avatar"
|
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: {
|
rooms: {
|
||||||
|
@@ -138,9 +138,15 @@ const fa: SynapseTranslationMessages = {
|
|||||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||||
|
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||||
|
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
|
||||||
|
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "پاک کردن اطلاعات کاربر",
|
erase: "پاک کردن اطلاعات کاربر",
|
||||||
|
erase_avatar: "محو الصورة الرمزية",
|
||||||
|
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
|
||||||
|
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -140,11 +140,15 @@ const fr: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
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.",
|
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: {
|
action: {
|
||||||
erase: "Effacer les données de l'utilisateur",
|
erase: "Effacer les données de l'utilisateur",
|
||||||
erase_avatar: "Effacer l'avatar",
|
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: {
|
rooms: {
|
||||||
|
4
src/i18n/index.d.ts
vendored
4
src/i18n/index.d.ts
vendored
@@ -138,11 +138,15 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deactivate: string;
|
deactivate: string;
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_text: string;
|
||||||
erase_admin_error: string;
|
erase_admin_error: string;
|
||||||
|
modify_managed_user_error: string;
|
||||||
};
|
};
|
||||||
action: {
|
action: {
|
||||||
erase: string;
|
erase: string;
|
||||||
erase_avatar: string;
|
erase_avatar: string;
|
||||||
|
delete_media: string;
|
||||||
|
redact_events: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -139,10 +139,15 @@ const it: SynapseTranslationMessages = {
|
|||||||
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
||||||
deactivate: "Devi fornire una password per riattivare l'account.",
|
deactivate: "Devi fornire una password per riattivare l'account.",
|
||||||
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
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: {
|
action: {
|
||||||
erase: "Cancella i dati dell'utente",
|
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: {
|
rooms: {
|
||||||
|
@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const ru: SynapseTranslationMessages = {
|
const ru: SynapseTranslationMessages = {
|
||||||
...russianMessages,
|
...russianMessages,
|
||||||
|
ra: {
|
||||||
|
...russianMessages.ra,
|
||||||
|
navigation: {
|
||||||
|
...russianMessages.ra.navigation,
|
||||||
|
no_filtered_results: "Нет результатов",
|
||||||
|
clear_filters: "Все фильтры сбросить",
|
||||||
|
},
|
||||||
|
},
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Адрес домашнего сервера",
|
base_url: "Адрес домашнего сервера",
|
||||||
@@ -151,11 +159,15 @@ const ru: SynapseTranslationMessages = {
|
|||||||
password: "Смена пароля завершит все сессии пользователя.",
|
password: "Смена пароля завершит все сессии пользователя.",
|
||||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||||
|
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
|
||||||
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||||
|
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Удалить данные пользователя",
|
erase: "Удалить данные пользователя",
|
||||||
erase_avatar: "Удалить аватар",
|
erase_avatar: "Удалить аватар",
|
||||||
|
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
|
||||||
|
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const zh: SynapseTranslationMessages = {
|
const zh: SynapseTranslationMessages = {
|
||||||
...chineseMessages,
|
...chineseMessages,
|
||||||
|
ra: {
|
||||||
|
...chineseMessages.ra,
|
||||||
|
navigation: {
|
||||||
|
...chineseMessages.ra.navigation,
|
||||||
|
no_filtered_results: "没有结果",
|
||||||
|
clear_filters: "清除所有过滤器",
|
||||||
|
},
|
||||||
|
},
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "服务器 URL",
|
base_url: "服务器 URL",
|
||||||
@@ -135,11 +143,15 @@ const zh: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "您必须提供一串密码来激活账户。",
|
deactivate: "您必须提供一串密码来激活账户。",
|
||||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||||
|
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
|
||||||
erase_admin_error: "不允许删除自己的用户",
|
erase_admin_error: "不允许删除自己的用户",
|
||||||
|
modify_managed_user_error: "不允许修改系统管理的用户。",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "抹除用户信息",
|
erase: "抹除用户信息",
|
||||||
erase_avatar: "抹掉头像",
|
erase_avatar: "抹掉头像",
|
||||||
|
delete_media: "删除用户上传的所有媒体",
|
||||||
|
redact_events: "重新编辑用户(-s)发送的所有事件",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -4,15 +4,18 @@ import { createRoot } from "react-dom/client";
|
|||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AppContext } from "./AppContext";
|
import { AppContext } from "./AppContext";
|
||||||
|
import storage from "./storage";
|
||||||
|
|
||||||
fetch("config.json")
|
fetch("config.json")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(props =>
|
.then(props => {
|
||||||
createRoot(document.getElementById("root")).render(
|
storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
|
||||||
|
storage.setItem("support_url", props.supportURL);
|
||||||
|
return createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AppContext.Provider value={props}>
|
<AppContext.Provider value={props}>
|
||||||
<App />
|
<App />
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
);
|
});
|
||||||
|
@@ -8,14 +8,17 @@ import { AppContext } from "../AppContext";
|
|||||||
import englishMessages from "../i18n/en";
|
import englishMessages from "../i18n/en";
|
||||||
|
|
||||||
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
|
||||||
describe("LoginForm", () => {
|
describe("LoginForm", () => {
|
||||||
it("renders with no restriction to homeserver", () => {
|
it("renders with no restriction to homeserver", async () => {
|
||||||
|
await act(async () => {
|
||||||
render(
|
render(
|
||||||
<AdminContext i18nProvider={i18nProvider}>
|
<AdminContext i18nProvider={i18nProvider}>
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
</AdminContext>
|
</AdminContext>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
||||||
screen.getByRole("combobox", { name: "" });
|
screen.getByRole("combobox", { name: "" });
|
||||||
|
@@ -222,7 +222,6 @@ const LoginPage = () => {
|
|||||||
disabled={loading || !supportPassAuth}
|
disabled={loading || !supportPassAuth}
|
||||||
onBlur={handleUsernameChange}
|
onBlur={handleUsernameChange}
|
||||||
resettable
|
resettable
|
||||||
fullWidth
|
|
||||||
validate={required()}
|
validate={required()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -234,7 +233,6 @@ const LoginPage = () => {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={loading || !supportPassAuth}
|
disabled={loading || !supportPassAuth}
|
||||||
resettable
|
resettable
|
||||||
fullWidth
|
|
||||||
validate={required()}
|
validate={required()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -247,7 +245,6 @@ const LoginPage = () => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
readOnly={allowSingleBaseUrl}
|
readOnly={allowSingleBaseUrl}
|
||||||
resettable={allowAnyBaseUrl}
|
resettable={allowAnyBaseUrl}
|
||||||
fullWidth
|
|
||||||
validate={[required(), validateBaseUrl]}
|
validate={[required(), validateBaseUrl]}
|
||||||
>
|
>
|
||||||
{allowMultipleBaseUrls &&
|
{allowMultipleBaseUrls &&
|
||||||
@@ -280,9 +277,9 @@ const LoginPage = () => {
|
|||||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||||
<Box className="form">
|
<Box className="form">
|
||||||
<Select
|
<Select
|
||||||
|
fullWidth
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={e => setLocale(e.target.value)}
|
onChange={e => setLocale(e.target.value)}
|
||||||
fullWidth
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="select"
|
className="select"
|
||||||
>
|
>
|
||||||
|
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import { DATE_FORMAT } from "../components/date";
|
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]} />;
|
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.msgtype" />
|
||||||
<TextField source="event_json.content.body" />
|
<TextField source="event_json.content.body" />
|
||||||
<TextField source="event_json.content.info.mimetype" />
|
<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.format" />
|
||||||
<TextField source="event_json.content.formatted_body" />
|
<TextField source="event_json.content.formatted_body" />
|
||||||
<TextField source="event_json.content.algorithm" />
|
<TextField source="event_json.content.algorithm" />
|
||||||
|
@@ -25,10 +25,10 @@ import {
|
|||||||
useRefresh,
|
useRefresh,
|
||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import AvatarField from "../components/AvatarField";
|
import AvatarField from "../components/AvatarField";
|
||||||
|
|
||||||
|
|
||||||
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
||||||
|
|
||||||
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
||||||
@@ -70,13 +70,12 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
|
|||||||
const refresh = useRefresh();
|
const refresh = useRefresh();
|
||||||
const unselectAllRooms = useUnselectAll("rooms");
|
const unselectAllRooms = useUnselectAll("rooms");
|
||||||
const dataProvider = useDataProvider();
|
const dataProvider = useDataProvider();
|
||||||
const { mutate, isLoading } = useMutation(
|
const { mutate, isPending } = useMutation({
|
||||||
() =>
|
mutationFn: () =>
|
||||||
dataProvider.createMany("room_directory", {
|
dataProvider.createMany("room_directory", {
|
||||||
ids: selectedIds,
|
ids: selectedIds,
|
||||||
data: {},
|
data: {},
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notify("resources.room_directory.action.send_success");
|
notify("resources.room_directory.action.send_success");
|
||||||
unselectAllRooms();
|
unselectAllRooms();
|
||||||
@@ -86,11 +85,10 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
|
|||||||
notify("resources.room_directory.action.send_failure", {
|
notify("resources.room_directory.action.send_failure", {
|
||||||
type: "error",
|
type: "error",
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
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 />
|
<RoomDirectoryIcon />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -102,6 +100,10 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
|
|||||||
const refresh = useRefresh();
|
const refresh = useRefresh();
|
||||||
const [create, { isLoading }] = useCreate();
|
const [create, { isLoading }] = useCreate();
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
create(
|
create(
|
||||||
"room_directory",
|
"room_directory",
|
||||||
@@ -142,7 +144,6 @@ export const RoomDirectoryList = () => (
|
|||||||
>
|
>
|
||||||
<AvatarField
|
<AvatarField
|
||||||
source="avatar_src"
|
source="avatar_src"
|
||||||
sortable={false}
|
|
||||||
sx={{ height: "40px", width: "40px" }}
|
sx={{ height: "40px", width: "40px" }}
|
||||||
label="resources.rooms.fields.avatar"
|
label="resources.rooms.fields.avatar"
|
||||||
/>
|
/>
|
||||||
|
@@ -47,6 +47,7 @@ import {
|
|||||||
} from "./room_directory";
|
} from "./room_directory";
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
import DeleteRoomButton from "../components/DeleteRoomButton";
|
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||||
|
import AvatarField from "../components/AvatarField";
|
||||||
|
|
||||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
@@ -67,6 +68,9 @@ const RoomTitle = () => {
|
|||||||
|
|
||||||
const RoomShowActions = () => {
|
const RoomShowActions = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
|
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
|
||||||
// FIXME: refresh after (un)publish
|
// FIXME: refresh after (un)publish
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
|
|||||||
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
||||||
<TabbedShowLayout>
|
<TabbedShowLayout>
|
||||||
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
<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="room_id" />
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
<TextField source="topic" />
|
<TextField source="topic" />
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import EqualizerIcon from "@mui/icons-material/Equalizer";
|
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
ExportButton,
|
ExportButton,
|
||||||
@@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (
|
|||||||
|
|
||||||
const resource: ResourceProps = {
|
const resource: ResourceProps = {
|
||||||
name: "user_media_statistics",
|
name: "user_media_statistics",
|
||||||
icon: EqualizerIcon,
|
icon: PermMediaIcon,
|
||||||
list: UserMediaStatsList,
|
list: UserMediaStatsList,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -36,7 +36,6 @@ import {
|
|||||||
ResourceProps,
|
ResourceProps,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
SelectInput,
|
SelectInput,
|
||||||
BulkDeleteButton,
|
|
||||||
DeleteButton,
|
DeleteButton,
|
||||||
maxLength,
|
maxLength,
|
||||||
regex,
|
regex,
|
||||||
@@ -57,10 +56,13 @@ import {
|
|||||||
RaRecord,
|
RaRecord,
|
||||||
ImageInput,
|
ImageInput,
|
||||||
ImageField,
|
ImageField,
|
||||||
|
FunctionField,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import AvatarField from "../components/AvatarField";
|
import AvatarField from "../components/AvatarField";
|
||||||
|
import DeleteUserButton from "../components/DeleteUserButton";
|
||||||
|
import { isASManaged } from "../components/mxid";
|
||||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
import { DeviceRemoveButton } from "../components/devices";
|
import { DeviceRemoveButton } from "../components/devices";
|
||||||
@@ -89,11 +91,6 @@ const UserListActions = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
UserListActions.defaultProps = {
|
|
||||||
selectedIds: [],
|
|
||||||
onUnselectItems: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
const userFilters = [
|
const userFilters = [
|
||||||
@@ -103,8 +100,9 @@ const userFilters = [
|
|||||||
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
<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 ownUserIsSelected = props.ownUserIsSelected;
|
||||||
|
const asManagedUserIsSelected = props.asManagedUserIsSelected;
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
@@ -112,6 +110,9 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
|
|||||||
if (ownUserIsSelected) {
|
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();
|
ev.stopPropagation();
|
||||||
|
} else if (asManagedUserIsSelected) {
|
||||||
|
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
|
||||||
|
ev.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
|
|||||||
const UserBulkActionButtons = () => {
|
const UserBulkActionButtons = () => {
|
||||||
const record = useListContext();
|
const record = useListContext();
|
||||||
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
|
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
|
||||||
|
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
||||||
const selectedIds = record.selectedIds;
|
const selectedIds = record.selectedIds;
|
||||||
const ownUserId = localStorage.getItem("user_id");
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
@@ -128,16 +130,17 @@ const UserBulkActionButtons = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||||
|
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
|
||||||
}, [selectedIds]);
|
}, [selectedIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ServerNoticeBulkButton />
|
<ServerNoticeBulkButton />
|
||||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
<BulkDeleteButton
|
<DeleteUserButton
|
||||||
label="resources.users.action.erase"
|
selectedIds={selectedIds}
|
||||||
confirmTitle="resources.users.helper.erase"
|
confirmTitle="resources.users.helper.erase"
|
||||||
mutationMode="pessimistic"
|
confirmContent="resources.users.helper.erase_text"
|
||||||
/>
|
/>
|
||||||
</UserPreventSelfDelete>
|
</UserPreventSelfDelete>
|
||||||
</>
|
</>
|
||||||
@@ -158,7 +161,7 @@ export const UserList = (props: ListProps) => (
|
|||||||
pagination={<UserPagination />}
|
pagination={<UserPagination />}
|
||||||
>
|
>
|
||||||
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
|
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
|
||||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
|
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} />
|
||||||
<TextField source="id" sortBy="name" />
|
<TextField source="id" sortBy="name" />
|
||||||
<TextField source="displayname" />
|
<TextField source="displayname" />
|
||||||
<BooleanField source="is_guest" />
|
<BooleanField source="is_guest" />
|
||||||
@@ -184,22 +187,24 @@ const UserEditActions = () => {
|
|||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
const ownUserId = localStorage.getItem("user_id");
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
let ownUserIsSelected = false;
|
let ownUserIsSelected = false;
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
if (record && record.id) {
|
if (record && record.id) {
|
||||||
ownUserIsSelected = record.id === ownUserId;
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopToolbar>
|
<TopToolbar>
|
||||||
{!record?.deactivated && <ServerNoticeButton />}
|
{!record?.deactivated && <ServerNoticeButton />}
|
||||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
{record && record.id && (
|
||||||
<DeleteButton
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
label="resources.users.action.erase"
|
<DeleteUserButton
|
||||||
confirmTitle={translate("resources.users.helper.erase", {
|
selectedIds={[record?.id]}
|
||||||
smart_count: 1,
|
confirmTitle="resources.users.helper.erase"
|
||||||
})}
|
confirmContent="resources.users.helper.erase_text"
|
||||||
mutationMode="pessimistic"
|
|
||||||
/>
|
/>
|
||||||
</UserPreventSelfDelete>
|
</UserPreventSelfDelete>
|
||||||
|
)}
|
||||||
</TopToolbar>
|
</TopToolbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -235,13 +240,21 @@ export const UserCreate = (props: CreateProps) => (
|
|||||||
|
|
||||||
const UserTitle = () => {
|
const UserTitle = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||||
|
if (isASManaged(record?.id)) {
|
||||||
|
username += " 🤖";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{translate("resources.users.name", {
|
{translate("resources.users.name", {
|
||||||
smart_count: 1,
|
smart_count: 1,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
{record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
|
{username}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -250,8 +263,10 @@ const UserEditToolbar = () => {
|
|||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
const ownUserId = localStorage.getItem("user_id");
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
let ownUserIsSelected = false;
|
let ownUserIsSelected = false;
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
if (record && record.id) {
|
if (record && record.id) {
|
||||||
ownUserIsSelected = record.id === ownUserId;
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,7 +274,7 @@ const UserEditToolbar = () => {
|
|||||||
<div className={ToolbarClasses.defaultToolbar}>
|
<div className={ToolbarClasses.defaultToolbar}>
|
||||||
<Toolbar sx={{ justifyContent: "space-between" }}>
|
<Toolbar sx={{ justifyContent: "space-between" }}>
|
||||||
<SaveButton />
|
<SaveButton />
|
||||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
<DeleteButton />
|
<DeleteButton />
|
||||||
</UserPreventSelfDelete>
|
</UserPreventSelfDelete>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
@@ -272,35 +287,58 @@ const UserBooleanInput = props => {
|
|||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
const ownUserId = localStorage.getItem("user_id");
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
let ownUserIsSelected = false;
|
let ownUserIsSelected = false;
|
||||||
if (record && record.id === ownUserId) {
|
let asManagedUserIsSelected = false;
|
||||||
ownUserIsSelected = true;
|
if (record) {
|
||||||
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
<BooleanInput {...props} disabled={ownUserIsSelected} />
|
<BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
|
||||||
</UserPreventSelfDelete>
|
</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) => {
|
export const UserEdit = (props: EditProps) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
|
||||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||||
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px" }} />
|
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
||||||
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
|
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
|
||||||
<ImageInput source="avatar_file" label="resources.users.fields.avatar" accept="image/*">
|
<ImageInput
|
||||||
<ImageField source="src" title="Avatar" />
|
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>
|
</ImageInput>
|
||||||
<TextInput source="id" disabled />
|
<TextInput source="id" readOnly />
|
||||||
<TextInput source="displayname" />
|
<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 />
|
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||||
<BooleanInput source="admin" />
|
<BooleanInput source="admin" />
|
||||||
<BooleanInput source="locked" />
|
<UserBooleanInput source="locked" />
|
||||||
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||||
<BooleanInput source="erased" disabled />
|
<BooleanInput source="erased" disabled />
|
||||||
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||||
@@ -327,7 +365,7 @@ export const UserEdit = (props: EditProps) => {
|
|||||||
|
|
||||||
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
|
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
|
||||||
<ReferenceManyField reference="devices" target="user_id" label={false}>
|
<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="device_id" sortable={false} />
|
||||||
<TextField source="display_name" sortable={false} />
|
<TextField source="display_name" sortable={false} />
|
||||||
<TextField source="last_seen_ip" sortable={false} />
|
<TextField source="last_seen_ip" sortable={false} />
|
||||||
@@ -367,8 +405,8 @@ export const UserEdit = (props: EditProps) => {
|
|||||||
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
||||||
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
||||||
<NumberField source="media_length" />
|
<NumberField source="media_length" />
|
||||||
<TextField source="media_type" />
|
<TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
|
||||||
<TextField source="upload_name" />
|
<FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
|
||||||
<TextField source="quarantined_by" />
|
<TextField source="quarantined_by" />
|
||||||
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
||||||
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
||||||
|
@@ -96,9 +96,14 @@ const authProvider: AuthProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (typeof access_token === "string") {
|
if (typeof access_token === "string") {
|
||||||
|
try {
|
||||||
await fetchUtils.fetchJson(logout_api_url, options);
|
await fetchUtils.fetchJson(logout_api_url, options);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error logging out", err);
|
||||||
|
} finally {
|
||||||
storage.removeItem("access_token");
|
storage.removeItem("access_token");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// called when the API returns an error
|
// called when the API returns an error
|
||||||
checkError: (err: HttpError) => {
|
checkError: (err: HttpError) => {
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { stringify } from "query-string";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DataProvider,
|
DataProvider,
|
||||||
DeleteParams,
|
DeleteParams,
|
||||||
|
DeleteManyParams,
|
||||||
HttpError,
|
HttpError,
|
||||||
Identifier,
|
Identifier,
|
||||||
Options,
|
Options,
|
||||||
|
PaginationPayload,
|
||||||
RaRecord,
|
RaRecord,
|
||||||
|
SortPayload,
|
||||||
UpdateParams,
|
UpdateParams,
|
||||||
fetchUtils,
|
fetchUtils,
|
||||||
withLifecycleCallbacks,
|
withLifecycleCallbacks,
|
||||||
@@ -41,17 +42,16 @@ const jsonClient = async (url: string, options: Options = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mxcUrlToHttp = (mxcUrl: string) => {
|
const filterUndefined = (obj: Record<string, any>) => {
|
||||||
const homeserver = storage.getItem("base_url");
|
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
|
||||||
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`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
endpoint: string;
|
||||||
|
method?: string;
|
||||||
|
body?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -256,10 +256,10 @@ export interface SynapseDataProvider extends DataProvider {
|
|||||||
const resourceMap = {
|
const resourceMap = {
|
||||||
users: {
|
users: {
|
||||||
path: "/_synapse/admin/v2/users",
|
path: "/_synapse/admin/v2/users",
|
||||||
map: (u: User) => ({
|
map: async (u: User) => ({
|
||||||
...u,
|
...u,
|
||||||
id: returnMXID(u.name),
|
id: returnMXID(u.name),
|
||||||
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
|
avatar_src: u.avatar_url ? u.avatar_url : undefined,
|
||||||
is_guest: !!u.is_guest,
|
is_guest: !!u.is_guest,
|
||||||
admin: !!u.admin,
|
admin: !!u.admin,
|
||||||
deactivated: !!u.deactivated,
|
deactivated: !!u.deactivated,
|
||||||
@@ -447,7 +447,7 @@ const resourceMap = {
|
|||||||
id: rd.room_id,
|
id: rd.room_id,
|
||||||
public: !!rd.public,
|
public: !!rd.public,
|
||||||
guest_access: !!rd.guest_access,
|
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",
|
data: "chunk",
|
||||||
total: json => json.total_room_count_estimate,
|
total: json => json.total_room_count_estimate,
|
||||||
@@ -527,8 +527,8 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
getList: async (resource, params) => {
|
getList: async (resource, params) => {
|
||||||
console.log("getList " + resource);
|
console.log("getList " + resource);
|
||||||
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
||||||
const { page, perPage } = params.pagination;
|
const { page, perPage } = params.pagination as PaginationPayload;
|
||||||
const { field, order } = params.sort;
|
const { field, order } = params.sort as SortPayload;
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
const query = {
|
const query = {
|
||||||
from: from,
|
from: from,
|
||||||
@@ -550,11 +550,13 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
const endpoint_url = homeserver + res.path;
|
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 { json } = await jsonClient(url);
|
||||||
|
const formattedData = await json[res.data].map(res.map);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: json[res.data].map(res.map),
|
data: formattedData,
|
||||||
total: res.total(json, from, perPage),
|
total: res.total(json, from, perPage),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -604,7 +606,7 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
const ref = res.reference(params.id);
|
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);
|
const { json } = await jsonClient(endpoint_url);
|
||||||
return {
|
return {
|
||||||
@@ -710,7 +712,7 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteMany: async (resource, params) => {
|
deleteMany: async (resource, params) => {
|
||||||
console.log("deleteMany " + resource);
|
console.log("deleteMany " + resource, "params", params);
|
||||||
const homeserver = storage.getItem("base_url");
|
const homeserver = storage.getItem("base_url");
|
||||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||||
|
|
||||||
@@ -727,6 +729,7 @@ const baseDataProvider: SynapseDataProvider = {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: responses.map(({ json }) => json),
|
data: responses.map(({ json }) => json),
|
||||||
};
|
};
|
||||||
@@ -804,6 +807,39 @@ const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
|||||||
}
|
}
|
||||||
return params;
|
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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@@ -54,11 +54,6 @@ export const getSupportedLoginFlows = async baseUrl => {
|
|||||||
return response.json.flows;
|
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
|
* Generate a random MXID for current homeserver
|
||||||
* @returns full MXID as string
|
* @returns full MXID as string
|
||||||
|
43
src/utils/fetchMedia.ts
Normal file
43
src/utils/fetchMedia.ts
Normal 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;
|
||||||
|
};
|
@@ -24,7 +24,7 @@
|
|||||||
/* Strict Type-Checking Options */
|
/* Strict Type-Checking Options */
|
||||||
"strict": true /* Enable all 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. */,
|
"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. */
|
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||||
|
Reference in New Issue
Block a user