Compare commits
91 Commits
v0.10.3-et
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1d5511421d | ||
![]() |
b925c63171 | ||
![]() |
6faebaf9df | ||
![]() |
c698f57395 | ||
![]() |
9adc13e722 | ||
![]() |
a04b24a5d5 | ||
![]() |
cd1ca7c039 | ||
![]() |
86b4987b7f | ||
![]() |
a7cf647669 | ||
![]() |
80d40d2fb5 | ||
![]() |
b19e961a35 | ||
![]() |
08f5f8ebd2 | ||
![]() |
1edf196049 | ||
![]() |
1f66b4d14a | ||
![]() |
cec5b0af9a | ||
![]() |
2b0e1e7c0e | ||
![]() |
a613a88232 | ||
![]() |
7afce71bef | ||
![]() |
fe4ba22a03 | ||
![]() |
eb35d9e122 | ||
![]() |
fa79fecc9d | ||
![]() |
61366b3792 | ||
![]() |
b55033d983 | ||
![]() |
b1f42988c8 | ||
![]() |
ef05b366c3 | ||
![]() |
b3d52e7d23 | ||
![]() |
d1c4250b46 | ||
![]() |
8d44077b24 | ||
![]() |
44e8b82412 | ||
![]() |
791ae2c869 | ||
![]() |
ee3753466a | ||
![]() |
b7dc703157 | ||
![]() |
2eca0dcc33 | ||
![]() |
d2219c1667 | ||
![]() |
132ea6f97f | ||
![]() |
28ef08de03 | ||
![]() |
865e53388e | ||
![]() |
3a105bb8c7 | ||
![]() |
edcda7a202 | ||
![]() |
edd69273e2 | ||
![]() |
444bfacbd9 | ||
![]() |
970e0a550f | ||
![]() |
b3ef68d66e | ||
![]() |
31382a42ee | ||
![]() |
1a7748d1ef | ||
![]() |
039b28cc5c | ||
![]() |
57eae3edb3 | ||
![]() |
dadc9416c0 | ||
![]() |
eab2342114 | ||
![]() |
9cf2f83936 | ||
![]() |
d823856873 | ||
![]() |
9b96c7cec8 | ||
![]() |
f211aba873 | ||
![]() |
c0fc2d8937 | ||
![]() |
a88b397748 | ||
![]() |
abc922c956 | ||
![]() |
4f2cd38344 | ||
![]() |
ca71038874 | ||
![]() |
be867b6b0d | ||
![]() |
f2f540b429 | ||
![]() |
7feec4ba07 | ||
![]() |
1d5fef1e53 | ||
![]() |
9c40efde17 | ||
![]() |
53dff66978 | ||
![]() |
3a595247e8 | ||
![]() |
33f5f60e31 | ||
![]() |
9dd2ea57c9 | ||
![]() |
fae7a696de | ||
![]() |
49e8b2d0f5 | ||
![]() |
281d908d3f | ||
![]() |
bacc42fe9c | ||
![]() |
1c26a28ca9 | ||
![]() |
d3a04cd132 | ||
![]() |
e6060a23ac | ||
![]() |
4b7fbf483a | ||
![]() |
bc3c30da92 | ||
![]() |
1896f770d1 | ||
![]() |
99d0b9ad72 | ||
![]() |
944afb9056 | ||
![]() |
23f5a24803 | ||
![]() |
60ae00ac14 | ||
![]() |
26862fa708 | ||
![]() |
853d14c1ce | ||
![]() |
11a5cac709 | ||
![]() |
0d021021df | ||
![]() |
19302466ef | ||
![]() |
0594259ae4 | ||
![]() |
ba485bbb18 | ||
![]() |
9fc005032c | ||
![]() |
f5d6f24b30 | ||
![]() |
a42efe7eda |
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
6
.github/workflows/workflow.yml
vendored
6
.github/workflows/workflow.yml
vendored
@@ -25,10 +25,6 @@ jobs:
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --network-timeout=300000
|
||||
- name: Set version into manifest.json
|
||||
run: |
|
||||
TAG=$(git describe --tags --abbrev=0 || echo "latest")
|
||||
sed -i "s|\"icons\"|\"version\": \"$TAG\",\\n \"icons\"|g" public/manifest.json
|
||||
- name: Build
|
||||
run: yarn build --base=${{ env.base_path }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -52,7 +48,7 @@ jobs:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
130
README.md
130
README.md
@@ -1,4 +1,16 @@
|
||||
# Synapse Admin UI [](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||
<p align="center">
|
||||
<img alt="Synapse Admin Logo" src="./public/images/logo.webp" height="140" />
|
||||
<h3 align="center">
|
||||
Synapse Admin<br>
|
||||
<a href="https://matrix.to/#/#synapse-admin:etke.cc">
|
||||
<img alt="Community room" src="https://img.shields.io/badge/room-community_room-green?logo=matrix&label=%23synapse-admin%3Aetke.cc">
|
||||
</a><br>
|
||||
<img alt="License" src="https://img.shields.io/github/license/etkecc/synapse-admin">
|
||||
</h3>
|
||||
<p align="center">Manager your Synapse homeserver with ease</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -7,12 +19,13 @@ 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)
|
||||
* [Availability](#availability)
|
||||
* [Changes](#changes)
|
||||
* [Development](#development)
|
||||
* [Configuration](#configuration)
|
||||
* [Restricting available homeserver](#restricting-available-homeserver)
|
||||
* [Protecting appservice managed users](#protecting-appservice-managed-users)
|
||||
* [Adding custom menu items](#adding-custom-menu-items)
|
||||
* [Providing support URL](#providing-support-url)
|
||||
* [Usage](#usage)
|
||||
* [Supported Synapse](#supported-synapse)
|
||||
@@ -22,7 +35,7 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||
* [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)
|
||||
* [Serving Synapse Admin on a different path](#serving-synapse-admin-on-a-different-path)
|
||||
* [Development](#development-1)
|
||||
|
||||
<!-- vim-markdown-toc -->
|
||||
@@ -33,9 +46,11 @@ With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologie
|
||||
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
|
||||
user-friendly interface for managing Synapse homeservers.
|
||||
|
||||
### Available via CDN
|
||||
### Availability
|
||||
|
||||
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
|
||||
* As a core/default component on [etke.cc](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=synapse-admin)
|
||||
* Via CDN on [admin.etke.cc](https://admin.etke.cc)
|
||||
* As a component in [Matrix-Docker-Ansible-Deploy Playbook](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-synapse-admin.md)
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -51,8 +66,9 @@ The following changes are already implemented:
|
||||
* [Fix redirect URL after user creation](https://github.com/etkecc/synapse-admin/pull/16)
|
||||
* [Display actual Synapse errors](https://github.com/etkecc/synapse-admin/pull/17)
|
||||
* [Fix base_url being undefined on unsuccessful login](https://github.com/etkecc/synapse-admin/pull/18)
|
||||
* [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (CI only)
|
||||
* [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using theme colors)
|
||||
* [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (later replaced
|
||||
with a proper manifest.json generation on build)
|
||||
* [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using icons)
|
||||
* [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
|
||||
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
|
||||
* [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
|
||||
@@ -63,6 +79,18 @@ The following changes are already implemented:
|
||||
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
|
||||
* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
|
||||
* [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53)
|
||||
* [Login with access token](https://github.com/etkecc/synapse-admin/pull/58)
|
||||
* [Fix footer causing vertical scrollbar](https://github.com/etkecc/synapse-admin/pull/60)
|
||||
* [Custom Menu Items](https://github.com/etkecc/synapse-admin/pull/79)
|
||||
* [Add user profile to the top menu](https://github.com/etkecc/synapse-admin/pull/80)
|
||||
* [Enable visual customization](https://github.com/etkecc/synapse-admin/pull/81)
|
||||
* [Fix room state events display](https://github.com/etkecc/synapse-admin/pull/100)
|
||||
* [Sanitize CSV on import](https://github.com/etkecc/synapse-admin/pull/101)
|
||||
* Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available)
|
||||
* [Add option to control user's experimental features](https://github.com/etkecc/synapse-admin/pull/111)
|
||||
* [Add random password generation on user create/edit form](https://github.com/etkecc/synapse-admin/pull/123)
|
||||
* [Add option to set user's rate limits](https://github.com/etkecc/synapse-admin/pull/125)
|
||||
* [Support configuration via /.well-known/matrix/client](https://github.com/etkecc/synapse-admin/pull/126)
|
||||
|
||||
_the list will be updated as new changes are added_
|
||||
|
||||
@@ -79,7 +107,11 @@ After that open `http://localhost:5173` in your browser, login using the followi
|
||||
|
||||
## Configuration
|
||||
|
||||
You can use `config.json` file to configure synapse-admin
|
||||
You can use `config.json` file to configure Synapse Admin instance,
|
||||
and `/.well-known/matrix/client` file to provide Synapse Admin configuration specifically for your homeserver.
|
||||
In the latter case, any instance of Synapse Admin will automatically pick up the configuration from the homeserver.
|
||||
Note that configuration inside the `/.well-known/matrix/client` file should go under the `cc.etke.synapse-admin` key,
|
||||
and it will override the configuration from the `config.json` file.
|
||||
|
||||
The `config.json` can be injected into a Docker container using a bind mount.
|
||||
|
||||
@@ -104,6 +136,16 @@ Edit `config.json` to restrict either to a single homeserver:
|
||||
}
|
||||
```
|
||||
|
||||
similar for `/.well-known/matrix/client`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
or to a list of homeservers:
|
||||
|
||||
```json
|
||||
@@ -112,6 +154,16 @@ or to a list of homeservers:
|
||||
}
|
||||
```
|
||||
|
||||
similar for `/.well-known/matrix/client`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Protecting appservice managed users
|
||||
|
||||
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
|
||||
@@ -125,9 +177,55 @@ Example for [mautrix-telegram](https://github.com/mautrix/telegram)
|
||||
}
|
||||
```
|
||||
|
||||
similar for `/.well-known/matrix/client`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"asManagedUsers": ["^@telegram_[a-zA-Z0-9]+:example\\.com$"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding custom menu items
|
||||
|
||||
You can add custom menu items to the main menu by providing a `menu` array in the `config.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
similar for `/.well-known/matrix/client`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"menu": [
|
||||
{
|
||||
"label": "Contact support",
|
||||
"icon": "SupportAgent",
|
||||
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `icon` is one of the [preloaded icons](./src/components/icons.ts)
|
||||
|
||||
### 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`.
|
||||
**Deprecated**: use `menu` config option described above. Automatically migrated to the `menu` if the `supportURL` is present.
|
||||
|
||||
~~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
|
||||
{
|
||||
@@ -135,6 +233,16 @@ Synapse-Admin provides a support link in the main menu - `Contact support`. By d
|
||||
}
|
||||
```
|
||||
|
||||
similar for `/.well-known/matrix/client`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cc.etke.synapse-admin": {
|
||||
"supportURL": "https://example.com/support"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Supported Synapse
|
||||
@@ -214,7 +322,7 @@ You have three options:
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -222,7 +330,7 @@ If you downloaded the source code, use `yarn build --base=/my-prefix` to set a p
|
||||
|
||||
If you want to build your own Docker container, use the `BASE_PATH` argument.
|
||||
|
||||
We do not support directly changing the path where Synapse-Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse-Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
|
||||
We do not support directly changing the path where Synapse Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
|
||||
|
||||
Example for Traefik:
|
||||
|
||||
|
22
index.html
22
index.html
@@ -4,17 +4,14 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Synapse-Admin"
|
||||
/>
|
||||
<meta name="description" content="Synapse Admin" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<title>Synapse-Admin</title>
|
||||
<title>Synapse Admin</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -22,6 +19,11 @@
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.layout {
|
||||
min-height: 90vh !important;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -120,13 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<footer
|
||||
style="position: relative; z-index: 2; height: 2em; margin-top: 0; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
||||
<a id="copyright" href="https://github.com/etkecc/synapse-admin"
|
||||
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
|
||||
Synapse-Admin <b><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
|
||||
</a>
|
||||
</footer>
|
||||
<span id="js-version" style="display: none;"></span>
|
||||
</body>
|
||||
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||
<script>document.getElementById("js-version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||
</html>
|
||||
|
8
justfile
8
justfile
@@ -25,11 +25,15 @@ run-dev:
|
||||
stop-dev:
|
||||
@docker-compose -f docker-compose-dev.yml stop
|
||||
|
||||
|
||||
# register a user in the dev stack
|
||||
register-user localpart password *admin:
|
||||
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
|
||||
|
||||
|
||||
# run yarn {fix,lint,test} commands
|
||||
test:
|
||||
@-yarn run fix
|
||||
@-yarn run lint
|
||||
@-yarn run test
|
||||
|
||||
# run the app in a production mode
|
||||
run-prod: build
|
||||
|
61
package.json
61
package.json
@@ -3,7 +3,7 @@
|
||||
"version": "0.10.3",
|
||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||
"type": "module",
|
||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
||||
"author": "etke.cc (originally by Awesome Technologies Innovationslabor GmbH)",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": ".",
|
||||
"repository": {
|
||||
@@ -11,25 +11,25 @@
|
||||
"url": "https://github.com/etkecc/synapse-admin"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.7.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@types/react": "^18.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.8.7",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^18.3.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.11.0",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-yaml": "^1.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
@@ -38,41 +38,42 @@
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^7.16.1",
|
||||
"vite": "^5.4.6",
|
||||
"vite-plugin-version-mark": "^0.1.0"
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.12.2",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-version-mark": "^0.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@haleos/ra-language-german": "^1.0.0",
|
||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||
"@mui/icons-material": "^6.1.1",
|
||||
"@mui/material": "^6.1.1",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@mui/icons-material": "^6.1.5",
|
||||
"@mui/material": "^6.1.6",
|
||||
"@mui/utils": "^5.16.6",
|
||||
"@tanstack/react-query": "^5.59.19",
|
||||
"history": "^5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"papaparse": "^5.4.1",
|
||||
"ra-core": "^5.2.0",
|
||||
"ra-i18n-polyglot": "^5.2.0",
|
||||
"ra-language-english": "^5.2.0",
|
||||
"ra-core": "^5.3.2",
|
||||
"ra-i18n-polyglot": "^5.3.2",
|
||||
"ra-language-english": "^5.3.2",
|
||||
"ra-language-farsi": "^5.0.0",
|
||||
"ra-language-french": "^5.2.0",
|
||||
"ra-language-french": "^5.3.1",
|
||||
"ra-language-italian": "^3.13.1",
|
||||
"ra-language-russian": "^4.14.2",
|
||||
"react": "^18.3.1",
|
||||
"react-admin": "^5.2.0",
|
||||
"react-admin": "^5.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-is": "^18.3.1",
|
||||
"react-router": "^6.26.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .",
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ignore-path .gitignore --ignore-pattern testdata/ --ext .ts,.tsx,.yml,.yaml .",
|
||||
"fix": "yarn lint --fix",
|
||||
"test": "yarn jest",
|
||||
"test:watch": "yarn jest --watch"
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/logo.webp
Normal file
BIN
public/images/logo.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"short_name": "Synapse-Admin",
|
||||
"name": "Synapse-Admin",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
fetchMock.enableMocks();
|
||||
|
||||
|
@@ -23,6 +23,7 @@ import users from "./resources/users";
|
||||
import authProvider from "./synapse/authProvider";
|
||||
import dataProvider from "./synapse/dataProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Footer from "./components/Footer";
|
||||
|
||||
// TODO: Can we use lazy loading together with browser locale?
|
||||
const messages = {
|
||||
@@ -81,6 +82,7 @@ const App = () => (
|
||||
<Resource name="room_state" />
|
||||
<Resource name="destination_rooms" />
|
||||
</Admin>
|
||||
<Footer />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
|
@@ -1,11 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface AppContextType {
|
||||
restrictBaseUrl: string | string[];
|
||||
asManagedUsers: string[];
|
||||
supportURL: string;
|
||||
}
|
||||
import { Config } from "./components/config";
|
||||
|
||||
export const AppContext = createContext({});
|
||||
|
||||
export const useAppContext = () => useContext(AppContext) as AppContextType;
|
||||
export const useAppContext = () => useContext(AppContext) as Config;
|
||||
|
@@ -1,26 +1,103 @@
|
||||
import { Layout, Menu } from 'react-admin';
|
||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||
import { AppBar, TitlePortal, InspectorButton, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin";
|
||||
import { LoginMethod } from "../pages/LoginPage";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { Icons, DefaultIcon } from "./icons";
|
||||
import { ClearConfig } from "./config";
|
||||
|
||||
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 AdminUserMenu = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const logout = useLogout();
|
||||
const checkLoginType = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod;
|
||||
if (loginType === "accessToken") {
|
||||
ev.stopPropagation();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
};
|
||||
|
||||
const AdminMenu = () => (
|
||||
<Menu>
|
||||
const handleDialogClose = () => {
|
||||
setOpen(false);
|
||||
ClearConfig();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<UserMenu>
|
||||
<div onClickCapture={checkLoginType}>
|
||||
<Logout />
|
||||
</div>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="synapseadmin.auth.logout_acces_token_dialog.title"
|
||||
content="synapseadmin.auth.logout_acces_token_dialog.content"
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
confirm="synapseadmin.auth.logout_acces_token_dialog.confirm"
|
||||
cancel="synapseadmin.auth.logout_acces_token_dialog.cancel"
|
||||
/>
|
||||
</UserMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminAppBar = () => {
|
||||
return (<AppBar userMenu={<AdminUserMenu />}>
|
||||
<TitlePortal />
|
||||
<InspectorButton />
|
||||
</AppBar>);
|
||||
};
|
||||
|
||||
const AdminMenu = (props) => {
|
||||
const [menu, setMenu] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const menuConfig = localStorage.getItem('menu');
|
||||
if (menuConfig) {
|
||||
try {
|
||||
setMenu(JSON.parse(menuConfig));
|
||||
} catch (e) {
|
||||
console.error('Error parsing menu configuration', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.ResourceItems />
|
||||
<Menu.Item to={supportLink()} target="_blank" primaryText="Contact support" leftIcon={<LiveHelpIcon />} />
|
||||
{menu.map((item, index) => {
|
||||
const { url, icon, label } = item;
|
||||
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
|
||||
|
||||
return (
|
||||
<Suspense key={index}>
|
||||
<Menu.Item
|
||||
to={url}
|
||||
target="_blank"
|
||||
primaryText={label}
|
||||
leftIcon={IconComponent ? <IconComponent /> : <DefaultIcon />}
|
||||
onClick={props.onMenuClick}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminLayout = ({ children }) => (
|
||||
<Layout menu={AdminMenu}>
|
||||
<Layout appBar={AdminAppBar} menu={AdminMenu} sx={{
|
||||
['& .RaLayout-appFrame']: {
|
||||
minHeight: '90vh',
|
||||
height: '90vh',
|
||||
},
|
||||
['& .RaLayout-content']: {
|
||||
marginBottom: '3rem',
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
</Layout>
|
||||
);
|
||||
|
@@ -1,12 +1,11 @@
|
||||
import { get } from "lodash";
|
||||
import { Avatar, AvatarProps } from "@mui/material";
|
||||
import { useRecordContext } from "react-admin";
|
||||
import { FieldProps, useRecordContext } from "react-admin";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
|
||||
const AvatarField = ({ source, ...rest }: AvatarProps & { source: string, label?: string }) => {
|
||||
const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
|
||||
const { alt, classes, sizes, sx, variant } = rest;
|
||||
|
||||
const record = useRecordContext(rest);
|
||||
const mxcURL = get(record, source)?.toString();
|
||||
|
||||
|
95
src/components/ExperimentalFeatures.tsx
Normal file
95
src/components/ExperimentalFeatures.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useRecordContext } from "react-admin";
|
||||
import { useNotify } from "react-admin";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Switch, Typography } from "@mui/material";
|
||||
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
|
||||
|
||||
const experimentalFeaturesMap = {
|
||||
msc3881: "enable remotely toggling push notifications for another client",
|
||||
msc3575: "enable experimental sliding sync support",
|
||||
};
|
||||
const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => {
|
||||
const featureKey = props.featureKey;
|
||||
const featureValue = props.featureValue;
|
||||
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
|
||||
const [checked, setChecked] = useState(featureValue);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setChecked(event.target.checked);
|
||||
props.updateFeature(featureKey, event.target.checked);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Switch checked={checked} onChange={handleChange} />
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: "medium",
|
||||
color: "text.primary"
|
||||
}}
|
||||
>
|
||||
{featureKey}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{featureDescription}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
|
||||
export const ExperimentalFeaturesList = () => {
|
||||
const record = useRecordContext();
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||
const [features, setFeatures] = useState({});
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeatures = async () => {
|
||||
const features = await dataProvider.getFeatures(record.id);
|
||||
setFeatures(features);
|
||||
}
|
||||
|
||||
fetchFeatures();
|
||||
}, []);
|
||||
|
||||
const updateFeature = async (feature_name: string, feature_value: boolean) => {
|
||||
const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel;
|
||||
setFeatures(updatedFeatures);
|
||||
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
|
||||
notify("ra.notification.updated", {
|
||||
messageArgs: { smart_count: 1 },
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
return <>
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
>
|
||||
{Object.keys(features).map((featureKey: string) =>
|
||||
<ExperimentalFeatureRow
|
||||
key={featureKey}
|
||||
featureKey={featureKey}
|
||||
featureValue={features[featureKey]}
|
||||
updateFeature={updateFeature}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
}
|
41
src/components/Footer.tsx
Normal file
41
src/components/Footer.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Avatar, Box, Link, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const Footer = () => {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const version = document.getElementById("js-version")?.textContent;
|
||||
if (version) {
|
||||
setVersion(version);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
bgcolor: "#eee",
|
||||
borderTop: '1px solid',
|
||||
borderColor: '#ddd',
|
||||
p: 1,
|
||||
}}>
|
||||
<Typography variant="body2" component="div">
|
||||
<Avatar src="./images/logo.webp" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />
|
||||
<Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
|
||||
Synapse Admin
|
||||
</Link> <Link href={`https://github.com/etkecc/synapse-admin/releases/tag/`+version} target="_blank">
|
||||
<span style={{ fontWeight: 'bold', color: "#000" }}>{version}</span>
|
||||
</Link> <Link sx={{ color: "#888", textDecoration: 'none' }} href="https://etke.cc/?utm_source=synapse-admin&utm_medium=footer&utm_campaign=synapse-admin" target="_blank">
|
||||
by etke.cc
|
||||
</Link> <Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/awesome-technologies/synapse-admin" target="_blank">
|
||||
(originally developed by Awesome Technologies Innovationslabor GmbH).
|
||||
</Link> <Link sx={{ fontWeight: 'bold', color: "#000", textDecoration: 'none' }} href="https://matrix.to/#/#synapse-admin:etke.cc" target="_blank">#synapse-admin:etke.cc</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
@@ -121,11 +121,8 @@ const FilePicker = () => {
|
||||
|
||||
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
||||
/* First, verify the presence of required fields */
|
||||
const missingFields = expectedFields.filter(eF => {
|
||||
const result = meta.fields?.find(mF => eF === mF);
|
||||
if (result === undefined) { return eF; } // missing field
|
||||
return undefined; // field found
|
||||
});
|
||||
meta.fields = meta.fields?.map(f => f.trim().toLowerCase());
|
||||
const missingFields = expectedFields.filter(eF => !meta.fields?.find(mF => eF === mF));
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
||||
@@ -151,6 +148,15 @@ const FilePicker = () => {
|
||||
};
|
||||
|
||||
const errorMessages = errors.map(e => e.message);
|
||||
// sanitize the data first
|
||||
data = data.map(line => {
|
||||
const newLine = {} as ImportLine;
|
||||
for (const [key, value] of Object.entries(line)) {
|
||||
newLine[key.trim().toLowerCase()] = value;
|
||||
}
|
||||
return newLine;
|
||||
});
|
||||
// process the data
|
||||
data.forEach((line, idx) => {
|
||||
if (line.user_type === undefined || line.user_type === "") {
|
||||
stats.user_types.default++;
|
||||
@@ -177,6 +183,7 @@ const FilePicker = () => {
|
||||
line[f] = true; // we need true booleans instead of strings
|
||||
} else {
|
||||
if (line[f] !== "false" && line[f] !== "") {
|
||||
console.log("invalid value", line[f], "for field " + f + " in row " + idx);
|
||||
errorMessages.push(
|
||||
translate("import_users.error.invalid_value", {
|
||||
field: f,
|
||||
|
58
src/components/LoginFormBox.tsx
Normal file
58
src/components/LoginFormBox.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
const LoginFormBox = styled(Box)(({ theme }) => ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "calc(100vh - 1rem)",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
background: "url(./images/floating-cogs.svg)",
|
||||
backgroundColor: "#f9f9f9",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
|
||||
[`& .card`]: {
|
||||
width: "30rem",
|
||||
marginTop: "6rem",
|
||||
marginBottom: "6rem",
|
||||
},
|
||||
[`& .avatar`]: {
|
||||
margin: "1rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
},
|
||||
[`& .icon`]: {
|
||||
backgroundColor: theme.palette.grey[500],
|
||||
},
|
||||
[`& .hint`]: {
|
||||
marginTop: "1em",
|
||||
marginBottom: "1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
[`& .form`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .select`]: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
[`& .actions`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .serverVersion`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
[`& .matrixVersions`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
marginBottom: "1rem",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
}));
|
||||
|
||||
export default LoginFormBox;
|
95
src/components/UserRateLimits.tsx
Normal file
95
src/components/UserRateLimits.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDataProvider, useNotify, useRecordContext, useTranslate } from "react-admin";
|
||||
import { TextField } from "@mui/material";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
const RateLimitRow = ({ limit, value, updateRateLimit }: { limit: string, value: any, updateRateLimit: (limit: string, value: any) => void }) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (isNaN(value)) {
|
||||
updateRateLimit(limit, null);
|
||||
return;
|
||||
}
|
||||
updateRateLimit(limit, value);
|
||||
};
|
||||
|
||||
return <Stack
|
||||
spacing={1}
|
||||
alignItems="start"
|
||||
sx={{
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
id="outlined-number"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
label={translate(`resources.users.limits.${limit}`)}
|
||||
/>
|
||||
<Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{translate(`resources.users.limits.${limit}_text`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
|
||||
export const UserRateLimits = () => {
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
const record = useRecordContext();
|
||||
const form = useFormContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const [rateLimits, setRateLimits] = useState({
|
||||
messages_per_second: "", // we are setting string here to make the number field empty by default, null is prohibited by the field validation
|
||||
burst_count: "",
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRateLimits = async () => {
|
||||
const rateLimits = await dataProvider.getRateLimits(record.id);
|
||||
if (Object.keys(rateLimits).length > 0) {
|
||||
setRateLimits(rateLimits);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRateLimits();
|
||||
}, []);
|
||||
|
||||
const updateRateLimit = async (limit: string, value: any) => {
|
||||
let updatedRateLimits = { ...rateLimits, [limit]: value };
|
||||
setRateLimits(updatedRateLimits);
|
||||
form.setValue(`rates.${limit}`, value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return <>
|
||||
<Stack
|
||||
direction="column"
|
||||
>
|
||||
{Object.keys(rateLimits).map((limit: string) =>
|
||||
<RateLimitRow
|
||||
key={limit}
|
||||
limit={limit}
|
||||
value={rateLimits[limit]}
|
||||
updateRateLimit={updateRateLimit}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
};
|
87
src/components/config.ts
Normal file
87
src/components/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import storage from "../storage";
|
||||
|
||||
export interface Config {
|
||||
restrictBaseUrl: string | string[];
|
||||
asManagedUsers: string[];
|
||||
supportURL: string;
|
||||
menu: MenuItem[];
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const WellKnownKey = "cc.etke.synapse-admin";
|
||||
|
||||
export const LoadConfig = (context: Config): Config => {
|
||||
if (context.restrictBaseUrl) {
|
||||
storage.setItem("restrict_base_url", JSON.stringify(context.restrictBaseUrl));
|
||||
}
|
||||
|
||||
if (context.asManagedUsers) {
|
||||
storage.setItem("as_managed_users", JSON.stringify(context.asManagedUsers));
|
||||
}
|
||||
|
||||
let menu: MenuItem[] = [];
|
||||
if (context.menu) {
|
||||
menu = context.menu;
|
||||
}
|
||||
if (context.supportURL) {
|
||||
const migratedSupportURL = {
|
||||
label: "Contact support",
|
||||
icon: "SupportAgent",
|
||||
url: context.supportURL,
|
||||
};
|
||||
console.warn("supportURL config option is deprecated. Please, use the menu option instead. Automatically migrated to the new menu option:", migratedSupportURL);
|
||||
menu.push(migratedSupportURL as MenuItem);
|
||||
}
|
||||
if (menu.length > 0) {
|
||||
storage.setItem("menu", JSON.stringify(menu));
|
||||
}
|
||||
|
||||
// below we try to calculate "final" config, which will contain values from context and already set values in storage
|
||||
// because LoadConfig could be called multiple times to get config from different sources
|
||||
let finalRestrictBaseUrl: string | string[] = "";
|
||||
try {
|
||||
finalRestrictBaseUrl = JSON.parse(storage.getItem("restrict_base_url") || "");
|
||||
if (Array.isArray(finalRestrictBaseUrl) && finalRestrictBaseUrl.length == 1) {
|
||||
finalRestrictBaseUrl = finalRestrictBaseUrl[0];
|
||||
}
|
||||
} catch (e) {}
|
||||
let finalAsManagedUsers: string[] = [];
|
||||
try {
|
||||
finalAsManagedUsers = JSON.parse(storage.getItem("as_managed_users") || "");
|
||||
} catch (e) {}
|
||||
|
||||
let finalMenu: MenuItem[] = [];
|
||||
try {
|
||||
finalMenu = JSON.parse(storage.getItem("menu") || "");
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
restrictBaseUrl: finalRestrictBaseUrl,
|
||||
asManagedUsers: finalAsManagedUsers,
|
||||
supportURL: storage.getItem("support_url") || "",
|
||||
menu: finalMenu,
|
||||
} as Config;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const ClearConfig = () => {
|
||||
// config.json
|
||||
storage.removeItem("restrict_base_url");
|
||||
storage.removeItem("as_managed_users");
|
||||
storage.removeItem("support_url");
|
||||
storage.removeItem("menu");
|
||||
|
||||
// session
|
||||
storage.removeItem("home_server");
|
||||
storage.removeItem("base_url");
|
||||
storage.removeItem("user_id");
|
||||
storage.removeItem("device_id");
|
||||
storage.removeItem("access_token");
|
||||
storage.removeItem("login_type");
|
||||
}
|
12
src/components/icons.ts
Normal file
12
src/components/icons.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { lazy } from "react";
|
||||
|
||||
export const Icons = {
|
||||
Announcement: lazy(() => import('@mui/icons-material/Announcement')),
|
||||
Engineering: lazy(() => import('@mui/icons-material/Engineering')),
|
||||
HelpCenter: lazy(() => import('@mui/icons-material/HelpCenter')),
|
||||
SupportAgent: lazy(() => import('@mui/icons-material/SupportAgent')),
|
||||
Default: lazy(() => import('@mui/icons-material/OpenInNew')),
|
||||
// Add more icons as needed
|
||||
};
|
||||
|
||||
export const DefaultIcon = Icons.Default;
|
@@ -2,7 +2,7 @@ import { formalGermanMessages } from "@haleos/ra-language-german";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const de: SynapseTranslationMessages = {
|
||||
const fixedGermanMessages = {
|
||||
...formalGermanMessages,
|
||||
ra: {
|
||||
...formalGermanMessages.ra,
|
||||
@@ -10,8 +10,30 @@ const de: SynapseTranslationMessages = {
|
||||
...formalGermanMessages.ra.navigation,
|
||||
no_filtered_results: "Keine Ergebnisse",
|
||||
clear_filters: "Alle Filter entfernen",
|
||||
add_filter: "Filter hinzufügen",
|
||||
},
|
||||
action: {
|
||||
...formalGermanMessages.ra.action,
|
||||
update_application: "Anwendung aktualisieren",
|
||||
},
|
||||
page: {
|
||||
...formalGermanMessages.ra.page,
|
||||
empty: "Leer",
|
||||
access_denied: "Zugriff verweigert",
|
||||
authentication_error: "Authentifizierungsfehler",
|
||||
},
|
||||
message: {
|
||||
...formalGermanMessages.ra.message,
|
||||
access_denied:
|
||||
"Sie haben nicht die richtigen Berechtigungen um auf diese Seite zuzugreifen.",
|
||||
authentication_error:
|
||||
"Der Authentifizierungsserver hat einen Fehler zurückgegeben und Ihre Anmeldedaten konnten nicht überprüft werden.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const de: SynapseTranslationMessages = {
|
||||
...fixedGermanMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Heimserver URL",
|
||||
@@ -22,10 +44,18 @@ const de: SynapseTranslationMessages = {
|
||||
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
||||
url_error: "Keine gültige Matrix Server URL",
|
||||
sso_sign_in: "Anmeldung mit SSO",
|
||||
credentials: "Anmeldedaten",
|
||||
access_token: "Zugriffstoken",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.",
|
||||
content: "Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?",
|
||||
confirm: "Sitzung beenden",
|
||||
cancel: "Nur vom Admin-Panel abmelden",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Experimentell", limits: "Rate Limits" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Raumdetails",
|
||||
@@ -160,7 +190,14 @@ const de: SynapseTranslationMessages = {
|
||||
erase_avatar: "Avatar löschen",
|
||||
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
|
||||
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
|
||||
generate_password: "Passwort generieren",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Nachrichten pro Sekunde",
|
||||
messages_per_second_text: "Die Anzahl der Aktionen, die in einer Sekunde durchgeführt werden können.",
|
||||
burst_count: "Burst-Anzahl",
|
||||
burst_count_text: "Die Anzahl der Aktionen, die vor der Begrenzung durchgeführt werden können.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Raum |||| Räume",
|
||||
|
@@ -14,10 +14,22 @@ const en: SynapseTranslationMessages = {
|
||||
protocol_error: "URL has to start with 'http://' or 'https://'",
|
||||
url_error: "Not a valid Matrix server URL",
|
||||
sso_sign_in: "Sign in with SSO",
|
||||
credentials: "Credentials",
|
||||
access_token: "Access token",
|
||||
logout_acces_token_dialog: {
|
||||
title: "You are using an existing Matrix access token.",
|
||||
content: "Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just logout from the admin panel?",
|
||||
confirm: "Destroy session",
|
||||
cancel: "Just logout from admin panel",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: {
|
||||
sso: "SSO",
|
||||
experimental: "Experimental",
|
||||
limits: "Rate Limits",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
details: "Room details",
|
||||
@@ -151,7 +163,14 @@ const en: SynapseTranslationMessages = {
|
||||
erase_avatar: "Erase avatar",
|
||||
delete_media: "Delete all media uploaded by the user(-s)",
|
||||
redact_events: "Redact all events sent by the user(-s)",
|
||||
generate_password: "Generate password",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messages per second",
|
||||
messages_per_second_text: "The number of actions that can be performed in a second.",
|
||||
burst_count: "Burst count",
|
||||
burst_count_text: "How many actions that can be performed before being limited.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Room |||| Rooms",
|
||||
|
@@ -13,10 +13,18 @@ const fa: SynapseTranslationMessages = {
|
||||
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
||||
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
||||
sso_sign_in: "با SSO وارد شوید",
|
||||
credentials: "اعتبارنامه",
|
||||
access_token: "توکن دسترسی",
|
||||
logout_acces_token_dialog: {
|
||||
title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.",
|
||||
content: "آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||
confirm: "نابودی جلسه",
|
||||
cancel: "فقط خروج از پنل مدیریت",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "تجربی", limits: "محدودیت ها" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -147,7 +155,14 @@ const fa: SynapseTranslationMessages = {
|
||||
erase_avatar: "محو الصورة الرمزية",
|
||||
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
|
||||
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||
generate_password: "توليد رمز عبور",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "پیام در ثانیه",
|
||||
messages_per_second_text: "تعداد عملیاتی که می تواند در یک ثانیه انجام شود.",
|
||||
burst_count: "تعداد پیچیدگی",
|
||||
burst_count_text: "تعداد عملیاتی که می تواند قبل از محدودیت انجام شود.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "اتاق |||| اتاق ها",
|
||||
@@ -199,6 +214,11 @@ const fa: SynapseTranslationMessages = {
|
||||
title: "حذف اتاق",
|
||||
content:
|
||||
"آیا مطمئن هستید که می خواهید اتاق را حذف کنید؟ این قابل بازگشت نیست. همه پیام ها و رسانه های مشترک در اتاق از سرور حذف می شوند!",
|
||||
fields: {
|
||||
block: "حذف",
|
||||
},
|
||||
success: "اتاق با موفقیت حذف شد.",
|
||||
failure: "خطایی رخ داده است.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -13,10 +13,18 @@ const fr: SynapseTranslationMessages = {
|
||||
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
||||
url_error: "L'URL du serveur Matrix n'est pas valide",
|
||||
sso_sign_in: "Se connecter avec l’authentification unique",
|
||||
credentials: "Identifiants",
|
||||
access_token: "Jeton d'accès",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Vous utilisez un jeton d'accès Matrix existant.",
|
||||
content: "Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?",
|
||||
confirm: "Détruire la session",
|
||||
cancel: "Se déconnecter simplement du panneau d'administration",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||
tabs: { sso: "Authentification unique" },
|
||||
tabs: { sso: "Authentification unique", experimental: "Expérimental", limits: "Limites" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -149,7 +157,14 @@ const fr: SynapseTranslationMessages = {
|
||||
erase_avatar: "Effacer l'avatar",
|
||||
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
|
||||
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
|
||||
generate_password: "Générer un mot de passe",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messages par seconde",
|
||||
messages_per_second_text: "Le nombre d'actions que l'utilisateur peut effectuer par seconde.",
|
||||
burst_count: "Compteur de pics",
|
||||
burst_count_text: "Le nombre d'actions que l'utilisateur peut effectuer avant d'être limité.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Salon |||| Salons",
|
||||
|
17
src/i18n/index.d.ts
vendored
17
src/i18n/index.d.ts
vendored
@@ -11,10 +11,18 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
protocol_error: string;
|
||||
url_error: string;
|
||||
sso_sign_in: string;
|
||||
credentials: string;
|
||||
access_token: string;
|
||||
logout_acces_token_dialog: {
|
||||
title: string;
|
||||
content: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
};
|
||||
};
|
||||
users: {
|
||||
invalid_user_id: string;
|
||||
tabs: { sso: string };
|
||||
tabs: { sso: string; experimental: string; limits: string; };
|
||||
};
|
||||
rooms: {
|
||||
details?: string; // TODO: fa, fr, it, zh
|
||||
@@ -147,6 +155,13 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
erase_avatar: string;
|
||||
delete_media: string;
|
||||
redact_events: string;
|
||||
generate_password: string;
|
||||
};
|
||||
limits: {
|
||||
messages_per_second: string;
|
||||
messages_per_second_text: string;
|
||||
burst_count: string;
|
||||
burst_count_text: string;
|
||||
};
|
||||
};
|
||||
rooms: {
|
||||
|
@@ -13,10 +13,18 @@ const it: SynapseTranslationMessages = {
|
||||
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
|
||||
url_error: "URL del server Matrix non valido",
|
||||
sso_sign_in: "Accedi con SSO",
|
||||
credentials: "Credenziali",
|
||||
access_token: "Token di accesso",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Stai utilizzando un token di accesso Matrix esistente.",
|
||||
content: "Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?",
|
||||
confirm: "Distruggi sessione",
|
||||
cancel: "Disconnetti solo dal pannello di amministrazione",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "ID utente non valido su questo homeserver.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Sperimentale", limits: "Limiti" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -148,7 +156,14 @@ const it: SynapseTranslationMessages = {
|
||||
erase_avatar: "Cancella l'avatar dell'utente",
|
||||
delete_media: "Elimina tutti i media caricati dall'utente(-s)",
|
||||
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
|
||||
generate_password: "Genera password",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Messaggi al secondo",
|
||||
messages_per_second_text: "Il numero di azioni che l'utente può eseguire al secondo.",
|
||||
burst_count: "Burst-conteggio",
|
||||
burst_count_text: "Il numero di azioni che l'utente può eseguire prima di essere limitato.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Stanza |||| Stanze",
|
||||
|
@@ -2,7 +2,7 @@ import russianMessages from "ra-language-russian";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
const fixedRussianMessages = {
|
||||
...russianMessages,
|
||||
ra: {
|
||||
...russianMessages.ra,
|
||||
@@ -11,7 +11,24 @@ const ru: SynapseTranslationMessages = {
|
||||
no_filtered_results: "Нет результатов",
|
||||
clear_filters: "Все фильтры сбросить",
|
||||
},
|
||||
page: {
|
||||
...russianMessages.ra.page,
|
||||
empty: "Пусто",
|
||||
access_denied: "Доступ запрещен",
|
||||
authentication_error: "Ошибка аутентификации",
|
||||
},
|
||||
message: {
|
||||
...russianMessages.ra.message,
|
||||
access_denied:
|
||||
"У вас нет прав доступа к этой странице.",
|
||||
authentication_error:
|
||||
"Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
...fixedRussianMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Адрес домашнего сервера",
|
||||
@@ -22,10 +39,18 @@ const ru: SynapseTranslationMessages = {
|
||||
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
|
||||
url_error: "Неверный адрес сервера Matrix",
|
||||
sso_sign_in: "Вход через SSO",
|
||||
credentials: "Учетные данные",
|
||||
access_token: "Токен доступа",
|
||||
logout_acces_token_dialog: {
|
||||
title: "Вы используете существующий токен доступа Matrix.",
|
||||
content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?",
|
||||
confirm: "Завершить сессию",
|
||||
cancel: "Просто выйти из панели администрирования",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "Экспериментальные", limits: "Ограничения" },
|
||||
},
|
||||
rooms: {
|
||||
details: "Данные комнаты",
|
||||
@@ -168,7 +193,14 @@ const ru: SynapseTranslationMessages = {
|
||||
erase_avatar: "Удалить аватар",
|
||||
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
|
||||
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
|
||||
generate_password: "Сгенерировать пароль",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "Сообщений в секунду",
|
||||
messages_per_second_text: "Количество действий, которые могут быть выполнены в секунду.",
|
||||
burst_count: "Burst-счётчик",
|
||||
burst_count_text: "Количество действий, которые могут быть выполнены до ограничения.",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "Комната |||| Комнаты",
|
||||
|
@@ -2,7 +2,7 @@ import chineseMessages from "@haxqer/ra-language-chinese";
|
||||
|
||||
import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
const fixedChineseMessages = {
|
||||
...chineseMessages,
|
||||
ra: {
|
||||
...chineseMessages.ra,
|
||||
@@ -11,7 +11,27 @@ const zh: SynapseTranslationMessages = {
|
||||
no_filtered_results: "没有结果",
|
||||
clear_filters: "清除所有过滤器",
|
||||
},
|
||||
action: {
|
||||
...chineseMessages.ra.action,
|
||||
update_application: "Anwendung aktualisieren",
|
||||
},
|
||||
page: {
|
||||
...chineseMessages.ra.page,
|
||||
access_denied: "拒绝访问",
|
||||
authentication_error: "认证错误",
|
||||
},
|
||||
message: {
|
||||
...chineseMessages.ra.message,
|
||||
access_denied:
|
||||
"您没有访问此页面的权限。",
|
||||
authentication_error:
|
||||
"身份验证服务器返回错误,无法验证您的凭据。",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
...fixedChineseMessages,
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "服务器 URL",
|
||||
@@ -21,10 +41,18 @@ const zh: SynapseTranslationMessages = {
|
||||
protocol_error: "URL 需要以'http://'或'https://'作为起始",
|
||||
url_error: "不是一个有效的 Matrix 服务器地址",
|
||||
sso_sign_in: "使用 SSO 登录",
|
||||
credentials: "凭证",
|
||||
access_token: "访问令牌",
|
||||
logout_acces_token_dialog: {
|
||||
title: "您正在使用现有的 Matrix 访问令牌。",
|
||||
content: "您想销毁此会话(可能在其他地方使用,例如在 Matrix 客户端中)还是仅从管理面板退出?",
|
||||
confirm: "销毁会话",
|
||||
cancel: "仅从管理面板退出",
|
||||
},
|
||||
},
|
||||
users: {
|
||||
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
||||
tabs: { sso: "SSO" },
|
||||
tabs: { sso: "SSO", experimental: "实验性", limits: "限制" },
|
||||
},
|
||||
rooms: {
|
||||
tabs: {
|
||||
@@ -152,7 +180,14 @@ const zh: SynapseTranslationMessages = {
|
||||
erase_avatar: "抹掉头像",
|
||||
delete_media: "删除用户上传的所有媒体",
|
||||
redact_events: "重新编辑用户(-s)发送的所有事件",
|
||||
generate_password: "生成密码",
|
||||
},
|
||||
limits: {
|
||||
messages_per_second: "每秒消息数",
|
||||
messages_per_second_text: "每秒可以执行的操作数。",
|
||||
burst_count: "Burst-计数",
|
||||
burst_count_text: "在限制之前可以执行的操作数。",
|
||||
}
|
||||
},
|
||||
rooms: {
|
||||
name: "房间",
|
||||
|
@@ -3,19 +3,42 @@ import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import { Config, WellKnownKey, LoadConfig } from "./components/config";
|
||||
import { AppContext } from "./AppContext";
|
||||
import storage from "./storage";
|
||||
|
||||
fetch("config.json")
|
||||
.then(res => res.json())
|
||||
.then(props => {
|
||||
storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
|
||||
storage.setItem("support_url", props.supportURL);
|
||||
return createRoot(document.getElementById("root")).render(
|
||||
// load config.json
|
||||
let props: Config = {};
|
||||
try {
|
||||
const resp = await fetch("config.json");
|
||||
const configJSON = await resp.json();
|
||||
console.log("Loaded config.json", configJSON);
|
||||
props = LoadConfig(configJSON as Config);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// if home_server is set, try to load https://home_server/.well-known/matrix/client
|
||||
const homeserver = storage.getItem("home_server");
|
||||
if (homeserver) {
|
||||
try {
|
||||
const resp = await fetch(`https://${homeserver}/.well-known/matrix/client`);
|
||||
const configWK = await resp.json();
|
||||
if (!configWK[WellKnownKey]) {
|
||||
console.log(`Loaded https://${homeserver}.well-known/matrix/client, but it doesn't contain ${WellKnownKey} key, skipping`, configWK);
|
||||
} else {
|
||||
console.log(`Loaded https://${homeserver}.well-known/matrix/client`, configWK);
|
||||
props = LoadConfig(configWK[WellKnownKey] as Config);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`https://${homeserver}/.well-known/matrix/client not found, skipping`, e);
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<AppContext.Provider value={props}>
|
||||
<App />
|
||||
</AppContext.Provider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
});
|
||||
);
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography } from "@mui/material";
|
||||
import {
|
||||
Form,
|
||||
FormDataConsumer,
|
||||
@@ -17,7 +15,7 @@ import {
|
||||
useLocales,
|
||||
} from "react-admin";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import LoginFormBox from "../components/LoginFormBox";
|
||||
import { useAppContext } from "../AppContext";
|
||||
import {
|
||||
getServerVersion,
|
||||
@@ -29,66 +27,18 @@ import {
|
||||
} from "../synapse/synapse";
|
||||
import storage from "../storage";
|
||||
|
||||
const FormBox = styled(Box)(({ theme }) => ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "calc(100vh - 1rem)",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
background: "url(./images/floating-cogs.svg)",
|
||||
backgroundColor: "#f9f9f9",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
|
||||
[`& .card`]: {
|
||||
width: "30rem",
|
||||
marginTop: "6rem",
|
||||
marginBottom: "6rem",
|
||||
},
|
||||
[`& .avatar`]: {
|
||||
margin: "1rem",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
},
|
||||
[`& .icon`]: {
|
||||
backgroundColor: theme.palette.grey[500],
|
||||
},
|
||||
[`& .hint`]: {
|
||||
marginTop: "1em",
|
||||
marginBottom: "1em",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
color: theme.palette.grey[600],
|
||||
},
|
||||
[`& .form`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .select`]: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
[`& .actions`]: {
|
||||
padding: "0 1rem 1rem 1rem",
|
||||
},
|
||||
[`& .serverVersion`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
[`& .matrixVersions`]: {
|
||||
color: theme.palette.grey[500],
|
||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||
fontSize: "0.8rem",
|
||||
marginBottom: "1rem",
|
||||
marginLeft: "0.5rem",
|
||||
},
|
||||
}));
|
||||
export type LoginMethod = "credentials" | "accessToken";
|
||||
|
||||
const LoginPage = () => {
|
||||
const login = useLogin();
|
||||
const notify = useNotify();
|
||||
const { restrictBaseUrl } = useAppContext();
|
||||
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
|
||||
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
|
||||
const allowSingleBaseUrl = typeof restrictBaseUrl === "string" && restrictBaseUrl !== "";
|
||||
const allowMultipleBaseUrls =
|
||||
Array.isArray(restrictBaseUrl) &&
|
||||
restrictBaseUrl.length > 0 &&
|
||||
restrictBaseUrl[0] !== "" &&
|
||||
restrictBaseUrl[0] !== null;
|
||||
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [supportPassAuth, setSupportPassAuth] = useState(true);
|
||||
@@ -98,8 +48,13 @@ const LoginPage = () => {
|
||||
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url");
|
||||
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
||||
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginToken) {
|
||||
const ssoToken = loginToken[1];
|
||||
console.log("SSO token is", ssoToken);
|
||||
// Prevent further requests
|
||||
@@ -127,7 +82,7 @@ const LoginPage = () => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [loginToken]);
|
||||
|
||||
const validateBaseUrl = value => {
|
||||
if (!value.match(/^(http|https):\/\//)) {
|
||||
@@ -212,6 +167,18 @@ const LoginPage = () => {
|
||||
}, [formData.base_url, form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={loginMethod}
|
||||
onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
centered
|
||||
>
|
||||
<Tab label={translate("synapseadmin.auth.credentials")} value="credentials" />
|
||||
<Tab label={translate("synapseadmin.auth.access_token")} value="accessToken" />
|
||||
</Tabs>
|
||||
{loginMethod === "credentials" ? (
|
||||
<>
|
||||
<Box>
|
||||
<TextInput
|
||||
@@ -236,6 +203,18 @@ const LoginPage = () => {
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<TextInput
|
||||
source="accessToken"
|
||||
label="synapseadmin.auth.access_token"
|
||||
disabled={loading}
|
||||
resettable
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<TextInput
|
||||
source="base_url"
|
||||
@@ -263,15 +242,13 @@ const LoginPage = () => {
|
||||
|
||||
return (
|
||||
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
|
||||
<FormBox>
|
||||
<LoginFormBox>
|
||||
<Card className="card">
|
||||
<Box className="avatar">
|
||||
{loading ? (
|
||||
<CircularProgress size={25} thickness={2} />
|
||||
) : (
|
||||
<Avatar className="icon">
|
||||
<LockIcon />
|
||||
</Avatar>
|
||||
<Avatar sx={{ width: "120px", height: "120px" }} src="./images/logo.webp"/>
|
||||
)}
|
||||
</Box>
|
||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||
@@ -290,7 +267,7 @@ const LoginPage = () => {
|
||||
))}
|
||||
</Select>
|
||||
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
|
||||
<CardActions className="actions">
|
||||
{loginMethod === "credentials" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
@@ -309,10 +286,21 @@ const LoginPage = () => {
|
||||
>
|
||||
{translate("synapseadmin.auth.sso_sign_in")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</CardActions>}
|
||||
{loginMethod === "accessToken" && <CardActions className="actions">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{translate("ra.auth.sign_in")}
|
||||
</Button>
|
||||
</CardActions>}
|
||||
</Box>
|
||||
</Card>
|
||||
</FormBox>
|
||||
</LoginFormBox>
|
||||
<Notification />
|
||||
</Form>
|
||||
);
|
||||
|
@@ -4,9 +4,11 @@ import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import DestinationsIcon from "@mui/icons-material/CloudQueue";
|
||||
import FolderSharedIcon from "@mui/icons-material/FolderShared";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import {
|
||||
Button,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
List,
|
||||
ListProps,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
Tab,
|
||||
TabbedShowLayout,
|
||||
TextField,
|
||||
FunctionField,
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useDelete,
|
||||
@@ -35,13 +38,6 @@ import { get } from "lodash";
|
||||
|
||||
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
const destinationRowSx = (record: RaRecord) => ({
|
||||
backgroundColor: record.retry_last_ts > 0 ? "warning.light" : "primary.contrastText",
|
||||
"& .MuiButtonBase-root": {
|
||||
color: "primary.dark",
|
||||
},
|
||||
});
|
||||
|
||||
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
|
||||
|
||||
export const DestinationReconnectButton = () => {
|
||||
@@ -105,7 +101,22 @@ const RetryDateField = (props: DateFieldProps) => {
|
||||
return <DateField {...props} />;
|
||||
};
|
||||
|
||||
|
||||
const destinationFieldRender = (record: RaRecord) => {
|
||||
if (record.retry_last_ts > 0) {
|
||||
return (
|
||||
<>
|
||||
<ErrorIcon fontSize="inherit" color="error" sx={{verticalAlign: "middle"}}/>
|
||||
|
||||
{record.destination}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <> {record.destination} </>;
|
||||
}
|
||||
|
||||
export const DestinationList = (props: ListProps) => {
|
||||
const record = useRecordContext(props);
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
@@ -113,14 +124,14 @@ export const DestinationList = (props: ListProps) => {
|
||||
pagination={<DestinationPagination />}
|
||||
sort={{ field: "destination", order: "ASC" }}
|
||||
>
|
||||
<Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
||||
<TextField source="destination" />
|
||||
<DatagridConfigurable rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
||||
<FunctionField source="destination" render={destinationFieldRender} />
|
||||
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
|
||||
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
|
||||
<TextField source="retry_interval" />
|
||||
<TextField source="last_successful_stream_ordering" />
|
||||
<DestinationReconnectButton />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
@@ -4,6 +4,7 @@ import {
|
||||
Create,
|
||||
CreateProps,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
DateTimeInput,
|
||||
Edit,
|
||||
@@ -39,13 +40,13 @@ export const RegistrationTokenList = (props: ListProps) => (
|
||||
pagination={false}
|
||||
perPage={500}
|
||||
>
|
||||
<Datagrid rowClick="edit">
|
||||
<DatagridConfigurable rowClick="edit">
|
||||
<TextField source="token" sortable={false} />
|
||||
<NumberField source="uses_allowed" sortable={false} />
|
||||
<NumberField source="pending" sortable={false} />
|
||||
<NumberField source="completed" sortable={false} />
|
||||
<DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
@@ -3,6 +3,7 @@ import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import ReportIcon from "@mui/icons-material/Warning";
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
DeleteButton,
|
||||
List,
|
||||
@@ -90,13 +91,13 @@ const ReportShowActions = () => {
|
||||
|
||||
export const ReportList = (props: ListProps) => (
|
||||
<List {...props} pagination={<ReportPagination />} sort={{ field: "received_ts", order: "DESC" }}>
|
||||
<Datagrid rowClick="show" bulkActionButtons={false}>
|
||||
<DatagridConfigurable rowClick="show" bulkActionButtons={false}>
|
||||
<TextField source="id" sortable={false} />
|
||||
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} />
|
||||
<TextField sortable={false} source="user_id" />
|
||||
<TextField sortable={false} source="name" />
|
||||
<TextField sortable={false} source="score" />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
@@ -187,7 +187,7 @@ export const RoomShow = (props: ShowProps) => {
|
||||
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||
<TextField source="type" sortable={false} />
|
||||
<DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} />
|
||||
<TextField source="content" sortable={false} />
|
||||
<FunctionField source="content" sortable={false} render={record => `${JSON.stringify(record.content, null, 2)}`} />
|
||||
<ReferenceField source="sender" reference="users" sortable={false}>
|
||||
<TextField source="id" />
|
||||
</ReferenceField>
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import {
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
ExportButton,
|
||||
List,
|
||||
ListProps,
|
||||
@@ -37,12 +38,12 @@ export const UserMediaStatsList = (props: ListProps) => (
|
||||
pagination={<UserMediaStatsPagination />}
|
||||
sort={{ field: "media_length", order: "DESC" }}
|
||||
>
|
||||
<Datagrid rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
|
||||
<DatagridConfigurable rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
|
||||
<TextField source="user_id" label="resources.users.fields.id" />
|
||||
<TextField source="displayname" label="resources.users.fields.displayname" />
|
||||
<NumberField source="media_count" />
|
||||
<NumberField source="media_length" />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
@@ -7,14 +7,17 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||
import ScienceIcon from "@mui/icons-material/Science";
|
||||
import LockClockIcon from '@mui/icons-material/LockClock';
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, ownerDocument } from "@mui/material";
|
||||
import { Alert } from "@mui/material";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
Button,
|
||||
Datagrid,
|
||||
DatagridConfigurable,
|
||||
DateField,
|
||||
Create,
|
||||
CreateProps,
|
||||
@@ -51,12 +54,12 @@ import {
|
||||
NumberField,
|
||||
useListContext,
|
||||
useNotify,
|
||||
ToolbarClasses,
|
||||
Identifier,
|
||||
RaRecord,
|
||||
ToolbarClasses,
|
||||
ImageInput,
|
||||
ImageField,
|
||||
FunctionField,
|
||||
useDataProvider,
|
||||
} from "react-admin";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -67,6 +70,10 @@ import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/Server
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { DeviceRemoveButton } from "../components/devices";
|
||||
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
||||
import { generateRandomPassword } from "../synapse/synapse";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures";
|
||||
import { UserRateLimits } from "../components/UserRateLimits";
|
||||
|
||||
const choices_medium = [
|
||||
{ id: "email", name: "resources.users.email" },
|
||||
@@ -125,8 +132,6 @@ const UserBulkActionButtons = () => {
|
||||
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
||||
const selectedIds = record.selectedIds;
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
const notify = useNotify();
|
||||
const translate = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||
@@ -147,10 +152,6 @@ const UserBulkActionButtons = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
|
||||
return `/users/${id}`;
|
||||
};
|
||||
|
||||
export const UserList = (props: ListProps) => (
|
||||
<List
|
||||
{...props}
|
||||
@@ -160,8 +161,11 @@ export const UserList = (props: ListProps) => (
|
||||
actions={<UserListActions />}
|
||||
pagination={<UserPagination />}
|
||||
>
|
||||
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} />
|
||||
<DatagridConfigurable
|
||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
|
||||
bulkActionButtons={<UserBulkActionButtons />}
|
||||
>
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
|
||||
<TextField source="id" sortBy="name" />
|
||||
<TextField source="displayname" />
|
||||
<BooleanField source="is_guest" />
|
||||
@@ -170,7 +174,7 @@ export const UserList = (props: ListProps) => (
|
||||
<BooleanField source="locked" />
|
||||
<BooleanField source="erased" sortable={false} />
|
||||
<DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
</Datagrid>
|
||||
</DatagridConfigurable>
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -212,9 +216,7 @@ const UserEditActions = () => {
|
||||
export const UserCreate = (props: CreateProps) => (
|
||||
<Create
|
||||
{...props}
|
||||
redirect={(resource, id, data) => {
|
||||
return `users/${id}`;
|
||||
}}
|
||||
redirect={(resource: string | undefined, id: Identifier | undefined) => `${resource}/${id}`}
|
||||
>
|
||||
<SimpleForm>
|
||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||
@@ -240,11 +242,11 @@ export const UserCreate = (props: CreateProps) => (
|
||||
|
||||
const UserTitle = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translate = useTranslate();
|
||||
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||
if (isASManaged(record?.id)) {
|
||||
username += " 🤖";
|
||||
@@ -303,12 +305,33 @@ const UserBooleanInput = props => {
|
||||
const UserPasswordInput = props => {
|
||||
const record = useRecordContext();
|
||||
let asManagedUserIsSelected = false;
|
||||
|
||||
// Get form context to update field value
|
||||
const form = useFormContext();
|
||||
if (record) {
|
||||
asManagedUserIsSelected = isASManaged(record.id);
|
||||
}
|
||||
|
||||
const generatePassword = () => {
|
||||
const password = generateRandomPassword();
|
||||
if (record) {
|
||||
form.setValue("password", password, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error" disabled={asManagedUserIsSelected} />
|
||||
<>
|
||||
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error"
|
||||
{...(asManagedUserIsSelected ? { disabled: true } : {})}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
label="Generate Password"
|
||||
onClick={generatePassword}
|
||||
sx={{ marginBottom: "10px" }}
|
||||
disabled={asManagedUserIsSelected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -316,7 +339,11 @@ export const UserEdit = (props: EditProps) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{
|
||||
meta: {
|
||||
include: ["features"] // Tell your dataProvider to include features
|
||||
}
|
||||
}}>
|
||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
||||
@@ -450,6 +477,14 @@ export const UserEdit = (props: EditProps) => {
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</FormTab>
|
||||
|
||||
<FormTab label="synapseadmin.users.tabs.experimental" icon={<ScienceIcon />} path="experimental">
|
||||
<ExperimentalFeaturesList />
|
||||
</FormTab>
|
||||
|
||||
<FormTab label="synapseadmin.users.tabs.limits" icon={<LockClockIcon />} path="limits">
|
||||
<UserRateLimits />
|
||||
</FormTab>
|
||||
</TabbedForm>
|
||||
</Edit>
|
||||
);
|
||||
|
@@ -23,14 +23,14 @@ describe("authProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const ret: undefined = await authProvider.login({
|
||||
const ret = await authProvider.login({
|
||||
base_url: "http://example.com",
|
||||
username: "@user:example.com",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
@@ -55,13 +55,13 @@ describe("authProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const ret: undefined = await authProvider.login({
|
||||
const ret = await authProvider.login({
|
||||
base_url: "https://example.com/",
|
||||
loginToken: "login_token",
|
||||
});
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
|
||||
expect(ret).toEqual({redirectTo: "/"});
|
||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", {
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
@@ -83,7 +83,7 @@ describe("authProvider", () => {
|
||||
|
||||
await authProvider.logout(null);
|
||||
|
||||
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
|
||||
expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer foo",
|
||||
@@ -123,7 +123,9 @@ describe("authProvider", () => {
|
||||
|
||||
describe("getPermissions", () => {
|
||||
it("should do nothing", async () => {
|
||||
if (authProvider.getPermissions) {
|
||||
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { AuthProvider, HttpError, Options, fetchUtils, useTranslate } from "react-admin";
|
||||
import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { MatrixError, displayError } from "../components/error";
|
||||
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||
import { ClearConfig } from "../components/config";
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
// called when the user attempts to log in
|
||||
@@ -10,14 +12,16 @@ const authProvider: AuthProvider = {
|
||||
username,
|
||||
password,
|
||||
loginToken,
|
||||
accessToken,
|
||||
}: {
|
||||
base_url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
loginToken: string;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
console.log("login ");
|
||||
const options: Options = {
|
||||
let options: Options = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
Object.assign(
|
||||
@@ -55,11 +59,30 @@ const authProvider: AuthProvider = {
|
||||
storage.setItem("base_url", base_url);
|
||||
|
||||
const decoded_base_url = window.decodeURIComponent(base_url);
|
||||
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
|
||||
let login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login");
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
if (accessToken) {
|
||||
// this a login with an already obtained access token, let's just validate it
|
||||
options = {
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetchUtils.fetchJson(login_api_url, options);
|
||||
const json = response.json;
|
||||
storage.setItem("home_server", accessToken ? json.user_id.split(":")[1] : json.home_server);
|
||||
storage.setItem("user_id", json.user_id);
|
||||
storage.setItem("access_token", accessToken ? accessToken : json.access_token);
|
||||
storage.setItem("device_id", json.device_id);
|
||||
storage.setItem("login_type", accessToken ? "accessToken" : "credentials");
|
||||
|
||||
return Promise.resolve({redirectTo: "/"});
|
||||
} catch(err) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
@@ -73,18 +96,49 @@ const authProvider: AuthProvider = {
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
getIdentity: async () => {
|
||||
const access_token = storage.getItem("access_token");
|
||||
const user_id = storage.getItem("user_id");
|
||||
const base_url = storage.getItem("base_url");
|
||||
|
||||
const json = response.json;
|
||||
storage.setItem("home_server", json.home_server);
|
||||
storage.setItem("user_id", json.user_id);
|
||||
storage.setItem("access_token", json.access_token);
|
||||
storage.setItem("device_id", json.device_id);
|
||||
if (typeof access_token !== "string" || typeof user_id !== "string" || typeof base_url !== "string") {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const whoami_api_url = base_url + `/_matrix/client/v3/profile/${user_id}`;
|
||||
|
||||
try {
|
||||
let avatar_url = "";
|
||||
const response = await fetchUtils.fetchJson(whoami_api_url, options);
|
||||
if (response.json.avatar_url) {
|
||||
const mediaresp = await fetchAuthenticatedMedia(response.json.avatar_url, "thumbnail");
|
||||
const blob = await mediaresp.blob();
|
||||
avatar_url = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: user_id,
|
||||
fullName: response.json.displayname,
|
||||
avatar: avatar_url,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error getting identity", err);
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
// called when the user clicks on the logout button
|
||||
logout: async () => {
|
||||
console.log("logout");
|
||||
|
||||
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/r0/logout";
|
||||
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/v3/logout";
|
||||
const access_token = storage.getItem("access_token");
|
||||
|
||||
const options: Options = {
|
||||
@@ -101,7 +155,7 @@ const authProvider: AuthProvider = {
|
||||
} catch (err) {
|
||||
console.log("Error logging out", err);
|
||||
} finally {
|
||||
storage.removeItem("access_token");
|
||||
ClearConfig();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -248,9 +248,23 @@ export interface UploadMediaResult {
|
||||
content_uri: string;
|
||||
}
|
||||
|
||||
export interface ExperimentalFeaturesModel {
|
||||
features: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RateLimitsModel {
|
||||
messages_per_second?: number;
|
||||
burst_count?: number;
|
||||
}
|
||||
|
||||
export interface SynapseDataProvider extends DataProvider {
|
||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
|
||||
getRateLimits: (id: Identifier) => Promise<RateLimitsModel>;
|
||||
setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise<void>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
@@ -575,13 +589,28 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
|
||||
getMany: async (resource, params) => {
|
||||
console.log("getMany " + resource);
|
||||
const homeserver = storage.getItem("base_url");
|
||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homerserver not set");
|
||||
const base_url = storage.getItem("base_url");
|
||||
const homeserver = storage.getItem("home_server");
|
||||
if (!base_url || !(resource in resourceMap)) throw Error("base_url not set");
|
||||
|
||||
const res = resourceMap[resource];
|
||||
|
||||
const endpoint_url = homeserver + res.path;
|
||||
const responses = await Promise.all(params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)));
|
||||
const endpoint_url = base_url + res.path;
|
||||
const responses = await Promise.all(params.ids.map(id => {
|
||||
// edge case: when user is external / federated, homeserver will return error, as querying external users via
|
||||
// /_synapse/admin/v2/users is not allowed.
|
||||
// That leads to an issue when a user is referenced (e.g., in room state datagrid) - the user cell is just empty.
|
||||
// To avoid that, we fake the response with one specific field (name) which is used in the datagrid.
|
||||
if (homeserver && resource === "users") {
|
||||
if (!(<string>id).endsWith(homeserver)) {
|
||||
const json = {
|
||||
name: id,
|
||||
};
|
||||
return Promise.resolve({ json });
|
||||
}
|
||||
}
|
||||
return jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`);
|
||||
}));
|
||||
return {
|
||||
data: responses.map(({ json }) => res.map(json)),
|
||||
total: responses.length,
|
||||
@@ -783,6 +812,40 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
});
|
||||
return json as UploadMediaResult;
|
||||
},
|
||||
getFeatures: async (id: Identifier) => {
|
||||
const base_url = storage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json.features as ExperimentalFeaturesModel;
|
||||
},
|
||||
updateFeatures: async (id: Identifier, features: ExperimentalFeaturesModel) => {
|
||||
const base_url = storage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||
await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) });
|
||||
},
|
||||
getRateLimits: async (id: Identifier) => {
|
||||
const base_url = storage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`;
|
||||
const { json } = await jsonClient(endpoint_url);
|
||||
return json as RateLimitsModel;
|
||||
},
|
||||
setRateLimits: async (id: Identifier, rateLimits: RateLimitsModel) => {
|
||||
const filtered = Object.entries(rateLimits).
|
||||
filter(([key, value]) => value !== null && value !== undefined).
|
||||
reduce((obj, [key, value]) => {
|
||||
obj[key] = value;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
const base_url = storage.getItem("base_url");
|
||||
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/override_ratelimit`;
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||
return
|
||||
}
|
||||
|
||||
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(filtered) });
|
||||
},
|
||||
};
|
||||
|
||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
@@ -791,6 +854,12 @@ const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
|
||||
const avatarFile = params.data.avatar_file?.rawFile;
|
||||
const avatarErase = params.data.avatar_erase;
|
||||
const rates = params.data.rates;
|
||||
|
||||
if (rates) {
|
||||
await dataProvider.setRateLimits(params.id, rates);
|
||||
delete params.data.rates;
|
||||
}
|
||||
|
||||
if (avatarErase) {
|
||||
params.data.avatar_url = "";
|
||||
|
@@ -91,8 +91,8 @@ export function returnMXID(input: string | Identifier): string {
|
||||
* Generate a random user password
|
||||
* @returns a new random password as string
|
||||
*/
|
||||
export function generateRandomPassword(length = 20): string {
|
||||
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$";
|
||||
export function generateRandomPassword(length = 64): string {
|
||||
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~`!@#$%^&*()_-+={[}]|:;'.?/<>,";
|
||||
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
|
||||
.map(x => characters[x % characters.length])
|
||||
.join("");
|
||||
|
@@ -5,13 +5,41 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
vitePluginVersionMark({
|
||||
command: "git describe --tags || git rev-parse --short HEAD",
|
||||
ifMeta: true,
|
||||
ifLog: true,
|
||||
name: "Synapse Admin",
|
||||
command: 'git describe --tags || git rev-parse --short HEAD || echo "${SYNAPSE_ADMIN_VERSION:-unknown}"',
|
||||
ifMeta: false,
|
||||
ifLog: false,
|
||||
ifGlobal: true,
|
||||
outputFile: (version) => ({
|
||||
path: "manifest.json",
|
||||
content: JSON.stringify({
|
||||
name: "Synapse Admin",
|
||||
version: version,
|
||||
description: "Synapse Admin is an admin console for synapse Matrix homeserver with additional features.",
|
||||
categories: ["productivity", "utilities"],
|
||||
orientation: "landscape",
|
||||
icons: [{
|
||||
src: "favicon.ico",
|
||||
sizes: "32x32",
|
||||
type: "image/x-icon"
|
||||
},{
|
||||
src: "images/logo.webp",
|
||||
sizes: "512x512",
|
||||
type: "image/webp",
|
||||
purpose: "any maskable"
|
||||
}],
|
||||
start_url: ".",
|
||||
display: "standalone",
|
||||
theme_color: "#000000",
|
||||
background_color: "#ffffff"
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
Reference in New Issue
Block a user