Compare commits
58 Commits
master
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
24cf0a60bf | ||
![]() |
d5113aad72 | ||
![]() |
6957cb1f7d | ||
![]() |
66c706532a | ||
![]() |
332e98a095 | ||
![]() |
f639a1c9ff | ||
![]() |
8282bb0926 | ||
![]() |
264d2ff59d | ||
![]() |
90a6c7d8c2 | ||
![]() |
7de9166648 | ||
![]() |
0bf3440fc8 | ||
![]() |
cceae77529 | ||
![]() |
1474b46ff0 | ||
![]() |
01e3947b22 | ||
![]() |
e093bd8625 | ||
![]() |
390aab5ce7 | ||
![]() |
fb1a04971b | ||
![]() |
eff17a0929 | ||
![]() |
8eaaaa50ec | ||
![]() |
85b305d117 | ||
![]() |
4cbea6ffb6 | ||
![]() |
1967546ae4 | ||
![]() |
c9a3294852 | ||
![]() |
f1839387e2 | ||
![]() |
c8246a1d19 | ||
![]() |
3c6259cc88 | ||
![]() |
317df5af0f | ||
![]() |
2142770a5b | ||
![]() |
51297b49fc | ||
![]() |
311cc2a1f4 | ||
![]() |
6bc760a6fa | ||
![]() |
50c96cfd77 | ||
![]() |
7747dc7f28 | ||
![]() |
678867e981 | ||
![]() |
4a4fae104e | ||
![]() |
265b5157af | ||
![]() |
b5b593945d | ||
![]() |
15c8a30c92 | ||
![]() |
40c6eb0b95 | ||
![]() |
4c4e3a07f6 | ||
![]() |
35322fa01d | ||
![]() |
88e88fb6b9 | ||
![]() |
7016e5a349 | ||
![]() |
c7e275d4ec | ||
![]() |
b1d3340fce | ||
![]() |
2ae5930578 | ||
![]() |
9dd6940383 | ||
![]() |
e2194f5c49 | ||
![]() |
e6dcbcb052 | ||
![]() |
5acf3042c3 | ||
![]() |
7286abaaae | ||
![]() |
0d5e95fa7c | ||
![]() |
c4b54c40fb | ||
![]() |
ec0b980e06 | ||
![]() |
94ccd3ad36 | ||
![]() |
49d67f9130 | ||
![]() |
2bb846734e | ||
![]() |
056d9c6b4c |
@@ -1,13 +0,0 @@
|
||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
||||
tests/
|
||||
build/
|
||||
dist/
|
||||
lib/
|
||||
node_modules/
|
||||
electron_app/
|
||||
karma-reports/
|
||||
.pnp.cjs
|
||||
.pnp.loader.mjs
|
||||
.idea/
|
||||
.tmp/
|
||||
config.json*
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
liberapay: etkecc
|
20
.github/dependabot.yml
vendored
20
.github/dependabot.yml
vendored
@@ -1,20 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Major updates for react-admin have breaking changes
|
||||
- dependency-name: "react-admin"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
26
.github/workflows/build-test.yml
vendored
26
.github/workflows/build-test.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: build-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: Install dependencies
|
||||
run: yarn --immutable
|
||||
- name: Run checks
|
||||
run: yarn lint
|
||||
- name: Run tests
|
||||
run: yarn test
|
63
.github/workflows/docker-release.yml
vendored
63
.github/workflows/docker-release.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Create docker image(s) and push to docker hub and ghcr.io
|
||||
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
|
||||
|
||||
on:
|
||||
push:
|
||||
# Sequence of patterns matched against refs/heads
|
||||
# prettier-ignore
|
||||
branches:
|
||||
# Push events on master branch
|
||||
- master
|
||||
# Sequence of patterns matched against refs/tags
|
||||
tags:
|
||||
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Push Docker image to multiple registries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
awesometechnologies/synapse-admin
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and Push Tag
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
31
.github/workflows/edge_ghpage.yml
vendored
31
.github/workflows/edge_ghpage.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Build and Deploy Edge version to GH Pages
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Install and Build 🔧
|
||||
run: |
|
||||
yarn install --immutable
|
||||
yarn build --base=/synapse-admin
|
||||
|
||||
- name: Deploy 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@v4.7.3
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: dist
|
30
.github/workflows/github-release.yml
vendored
30
.github/workflows/github-release.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Create release tarball and attach to tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: yarn install --immutable
|
||||
- run: yarn build
|
||||
- run: |
|
||||
version=`git describe --dirty --tags || echo unknown`
|
||||
cp -r dist synapse-admin-$version
|
||||
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
|
||||
- uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
with:
|
||||
files: dist/*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
119
.github/workflows/workflow.yml
vendored
Normal file
119
.github/workflows/workflow.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
env:
|
||||
bunny_version: v0.1.0
|
||||
base_path: ./
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --network-timeout=300000
|
||||
- name: 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
|
||||
with:
|
||||
path: dist/
|
||||
name: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
overwrite: true
|
||||
include-hidden-files: true
|
||||
|
||||
docker:
|
||||
name: Docker
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
registry.etke.cc/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
cdn:
|
||||
name: CDN
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Upload
|
||||
run: |
|
||||
wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz
|
||||
tar -xzf bunny-upload.tar.gz
|
||||
echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml
|
||||
./bunny-upload -c bunny-config.yaml
|
||||
|
||||
github-release:
|
||||
name: Github Release
|
||||
needs: build
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mv dist synapse-admin
|
||||
tar chvzf synapse-admin.tar.gz synapse-admin
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: synapse-admin.tar.gz
|
||||
generate_release_notes: true
|
||||
make_latest: "true"
|
||||
draft: false
|
||||
prerelease: false
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -191,3 +191,6 @@ sketch
|
||||
# .pnp.*
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
||||
|
||||
/testdata/synapse.data
|
||||
/testdata/postgres.data
|
||||
|
893
.yarn/releases/yarn-4.1.1.cjs
vendored
Executable file
893
.yarn/releases/yarn-4.1.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
925
.yarn/releases/yarn-4.4.1.cjs
vendored
925
.yarn/releases/yarn-4.4.1.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
yarnPath: .yarn/releases/yarn-4.4.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
||||
|
27
Dockerfile
27
Dockerfile
@@ -1,26 +1,5 @@
|
||||
# Builder
|
||||
FROM node:lts as builder
|
||||
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
|
||||
# Base path for synapse admin
|
||||
ARG BASE_PATH=./
|
||||
FROM ghcr.io/static-web-server/static-web-server:2
|
||||
|
||||
WORKDIR /src
|
||||
ENV SERVER_ROOT=/app
|
||||
|
||||
# Copy .yarn directory to the working directory (must be on a separate line!)
|
||||
# Use https://docs.docker.com/engine/reference/builder/#copy---parents when available
|
||||
COPY .yarn .yarn
|
||||
COPY package.json .yarnrc.yml yarn.lock ./
|
||||
|
||||
# Disable telemetry and install packages
|
||||
RUN yarn config set enableTelemetry 0 && yarn install --immutable --network-timeout=300000
|
||||
|
||||
COPY . /src
|
||||
RUN yarn build --base=$BASE_PATH
|
||||
|
||||
# App
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html \
|
||||
&& ln -s /app /usr/share/nginx/html
|
||||
COPY ./dist /app
|
||||
|
53
README.md
53
README.md
@@ -1,14 +1,51 @@
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||
[](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
|
||||
[](https://awesome-technologies.github.io/synapse-admin/)
|
||||
[](https://hub.docker.com/r/awesometechnologies/synapse-admin)
|
||||
[](https://github.com/Awesome-Technologies/synapse-admin/releases)
|
||||
|
||||
# Synapse admin ui
|
||||
# Synapse Admin UI [](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||
|
||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||
|
||||
## Fork differences
|
||||
|
||||
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
|
||||
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
|
||||
user-friendly interface for managing Synapse homeservers.
|
||||
|
||||
### Available via CDN
|
||||
|
||||
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
|
||||
|
||||
### Changes
|
||||
|
||||
The following changes are already implemented:
|
||||
|
||||
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
|
||||
* [Fix user's default tab not being shown](https://github.com/etkecc/synapse-admin/pull/8)
|
||||
* [Add identifier when authorizing with password](https://github.com/Awesome-Technologies/synapse-admin/pull/601)
|
||||
* [Add ability to toggle whether to show locked users](https://github.com/Awesome-Technologies/synapse-admin/pull/573)
|
||||
* [Fix user's display name in header on user's page](https://github.com/etkecc/synapse-admin/pull/9)
|
||||
* [Fix footer overlapping content](https://github.com/Awesome-Technologies/synapse-admin/issues/574)
|
||||
* Switch from nginx to [SWS](https://static-web-server.net/) for serving the app, reducing the size of the Docker image
|
||||
* [Fix redirect URL after user creation](https://github.com/etkecc/synapse-admin/pull/16)
|
||||
* [Display actual Synapse errors](https://github.com/etkecc/synapse-admin/pull/17)
|
||||
* [Fix base_url being undefined on unsuccessful login](https://github.com/etkecc/synapse-admin/pull/18)
|
||||
* [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (CI only)
|
||||
* [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using theme colors)
|
||||
* [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
|
||||
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
|
||||
* [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
|
||||
* [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
|
||||
|
||||
_the list will be updated as new changes are added_
|
||||
|
||||
### Development
|
||||
|
||||
`just run-dev` to start the development stack (depending on your system speed, you may want to re-run this command if
|
||||
user creation fails)
|
||||
|
||||
After that open `http://localhost:5173` in your browser, login using the following credentials:
|
||||
|
||||
* Login: admin
|
||||
* Password: admin
|
||||
* Homeserver URL: http://localhost:8008
|
||||
|
||||
## Usage
|
||||
|
||||
### Supported Synapse
|
||||
|
20
docker-compose-dev.yml
Normal file
20
docker-compose-dev.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
entrypoint: python
|
||||
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- ./testdata/synapse:/config
|
||||
- ./testdata/synapse.data:/media-store
|
||||
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
volumes:
|
||||
- ./testdata/postgres.data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: synapse
|
||||
POSTGRES_PASSWORD: synapse
|
||||
POSTGRES_DB: synapse
|
||||
POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"
|
@@ -2,13 +2,13 @@ services:
|
||||
synapse-admin:
|
||||
container_name: synapse-admin
|
||||
hostname: synapse-admin
|
||||
image: awesometechnologies/synapse-admin:latest
|
||||
image: ghcr.io/etkecc/synapse-admin:latest
|
||||
# build:
|
||||
# context: .
|
||||
|
||||
# to use the docker-compose as standalone without a local repo clone,
|
||||
# replace the context definition with this:
|
||||
# context: https://github.com/Awesome-Technologies/synapse-admin.git
|
||||
# context: https://github.com/etkecc/synapse-admin.git
|
||||
|
||||
# args:
|
||||
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
|
@@ -121,8 +121,8 @@
|
||||
</div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<footer
|
||||
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
||||
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
|
||||
style="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>
|
||||
|
40
justfile
Normal file
40
justfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# Shows help
|
||||
default:
|
||||
@just --list --justfile {{ justfile() }}
|
||||
|
||||
# build the app
|
||||
build: __install
|
||||
@yarn run build --base=./
|
||||
|
||||
# run the app in a development mode
|
||||
run:
|
||||
@yarn start --host 0.0.0.0
|
||||
|
||||
# run dev stack and start the app in a development mode
|
||||
run-dev:
|
||||
@echo "Starting the database..."
|
||||
@docker-compose -f docker-compose-dev.yml up -d postgres
|
||||
@echo "Starting Synapse..."
|
||||
@docker-compose -f docker-compose-dev.yml up -d synapse
|
||||
@echo "Ensure admin user is registered..."
|
||||
@docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
|
||||
@echo "Starting the app..."
|
||||
@yarn start --host 0.0.0.0
|
||||
|
||||
# stop the dev stack
|
||||
stop-dev:
|
||||
@docker-compose -f docker-compose-dev.yml stop
|
||||
|
||||
|
||||
register-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 the app in a production mode
|
||||
run-prod: build
|
||||
@python -m http.server -d dist 1313
|
||||
|
||||
# install the project
|
||||
__install:
|
||||
@yarn install --immutable --network-timeout=300000
|
31
package.json
31
package.json
@@ -8,13 +8,12 @@
|
||||
"homepage": ".",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Awesome-Technologies/synapse-admin"
|
||||
"url": "https://github.com/etkecc/synapse-admin"
|
||||
},
|
||||
"packageManager": "yarn@4.4.1",
|
||||
"packageManager": "yarn@4.1.1",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.7.0",
|
||||
"@mui/utils": "^6.1.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -31,7 +30,7 @@
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"eslint-plugin-yaml": "^1.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
@@ -41,8 +40,8 @@
|
||||
"ts-jest": "^29.2.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"vite": "^6.3.5",
|
||||
"typescript-eslint": "^7.16.1",
|
||||
"vite": "^5.3.4",
|
||||
"vite-plugin-version-mark": "^0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -52,25 +51,25 @@
|
||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||
"@mui/icons-material": "^5.16.4",
|
||||
"@mui/material": "^5.16.4",
|
||||
"@tanstack/react-query": "^5.59.12",
|
||||
"history": "^5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"papaparse": "^5.4.1",
|
||||
"query-string": "^7.1.3",
|
||||
"ra-core": "^5.2.3",
|
||||
"ra-i18n-polyglot": "^5.2.3",
|
||||
"ra-language-english": "^5.8.2",
|
||||
"ra-language-farsi": "^5.0.0",
|
||||
"ra-language-french": "^5.2.3",
|
||||
"ra-core": "^4.16.20",
|
||||
"ra-i18n-polyglot": "^4.16.20",
|
||||
"ra-language-english": "^4.16.20",
|
||||
"ra-language-farsi": "^4.2.0",
|
||||
"ra-language-french": "^4.16.20",
|
||||
"ra-language-italian": "^3.13.1",
|
||||
"ra-language-russian": "^4.14.2",
|
||||
"react": "^18.3.1",
|
||||
"react-admin": "^5.2.3",
|
||||
"react-admin": "^4.16.20",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-is": "^18.3.1",
|
||||
"react-router": "^6.28.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
"react-query": "^3.39.3",
|
||||
"react-router": "^6.25.1",
|
||||
"react-router-dom": "^6.25.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
|
@@ -1,6 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
fetchMock.enableMocks();
|
||||
|
||||
import App from "./App";
|
||||
|
||||
|
@@ -53,6 +53,7 @@ const App = () => (
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
darkTheme={{ palette: { mode: "dark" } }}
|
||||
>
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<ImportFeature />} />
|
||||
|
105
src/components/DeleteRoomButton.tsx
Normal file
105
src/components/DeleteRoomButton.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||
import { Fragment, useState } from "react";
|
||||
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
|
||||
import ActionDelete from "@mui/icons-material/Delete";
|
||||
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||
|
||||
interface DeleteRoomButtonProps {
|
||||
selectedIds: Identifier[];
|
||||
confirmTitle: string;
|
||||
confirmContent: string;
|
||||
}
|
||||
|
||||
const resourceName = "rooms";
|
||||
|
||||
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
|
||||
const translate = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [block, setBlock] = useState(true);
|
||||
|
||||
const notify = useNotify();
|
||||
const redirect = useRedirect();
|
||||
|
||||
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||
const unselectAll = useUnselectAll(resourceName);
|
||||
const recordIds = props.selectedIds;
|
||||
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = (values: {block: boolean}) => {
|
||||
deleteMany(
|
||||
resourceName,
|
||||
{ ids: recordIds, meta: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.rooms.action.erase.success");
|
||||
handleDialogClose();
|
||||
unselectAll();
|
||||
redirect("/rooms");
|
||||
},
|
||||
onError: (error) =>
|
||||
notify("resources.rooms.action.erase.failure", { type: 'error' as NotificationType }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
handleDelete({ block: block });
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
onClick={handleDialogOpen}
|
||||
disabled={isLoading}
|
||||
className={"ra-delete-button"}
|
||||
key="button"
|
||||
size="small"
|
||||
sx={{
|
||||
"&.MuiButton-sizeSmall": {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
}}
|
||||
color={"error"}
|
||||
startIcon={<ActionDelete />}
|
||||
>
|
||||
{translate("ra.action.delete")}
|
||||
</Button>
|
||||
<Dialog open={open} onClose={handleDialogClose}>
|
||||
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||
<SimpleForm toolbar={false}>
|
||||
<BooleanInput
|
||||
fullWidth
|
||||
source="block"
|
||||
value={block}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
|
||||
label="resources.rooms.action.erase.fields.block"
|
||||
defaultValue={true}
|
||||
/>
|
||||
</SimpleForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||
{translate("ra.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={false}
|
||||
onClick={handleConfirm}
|
||||
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||
autoFocus
|
||||
startIcon={<ActionCheck />}
|
||||
>
|
||||
{translate("ra.action.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteRoomButton;
|
@@ -15,7 +15,7 @@ import {
|
||||
import { DataProvider, useTranslate } from "ra-core";
|
||||
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
|
||||
|
||||
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
|
||||
import { generateRandomMxId, generateRandomPassword, returnMXID } from "../synapse/synapse";
|
||||
|
||||
const LOGGING = true;
|
||||
|
||||
@@ -74,7 +74,7 @@ const FilePicker = () => {
|
||||
|
||||
const [conflictMode, setConflictMode] = useState("stop");
|
||||
const [passwordMode, setPasswordMode] = useState(true);
|
||||
const [useridMode, setUseridMode] = useState("ignore");
|
||||
const [useridMode, setUseridMode] = useState("update");
|
||||
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
@@ -121,7 +121,11 @@ const FilePicker = () => {
|
||||
|
||||
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
||||
/* First, verify the presence of required fields */
|
||||
const missingFields = expectedFields.filter(eF => !meta.fields?.includes(eF));
|
||||
const missingFields = expectedFields.filter(eF => {
|
||||
const result = meta.fields?.find(mF => eF === mF);
|
||||
if (result === undefined) { return eF; } // missing field
|
||||
return undefined; // field found
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
||||
@@ -262,12 +266,15 @@ const FilePicker = () => {
|
||||
const userRecord = { ...entry };
|
||||
// No need to do a bunch of cryptographic random number getting if
|
||||
// we are using neither a generated password nor a generated user id.
|
||||
if (useridMode === "ignore" || userRecord.id === undefined) {
|
||||
if (useridMode === "ignore" || userRecord.id === undefined || userRecord.id === "") {
|
||||
userRecord.id = generateRandomMxId();
|
||||
}
|
||||
if (passwordMode === false || entry.password === undefined) {
|
||||
if (passwordMode === false || entry.password === undefined || entry.password === "") {
|
||||
userRecord.password = generateRandomPassword();
|
||||
}
|
||||
// we want to ensure that the ID is always full MXID, otherwise randomly-generated MXIDs will be in the full
|
||||
// form, but the ones from the CSV will be localpart-only.
|
||||
userRecord.id = returnMXID(userRecord.id);
|
||||
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
||||
|
||||
/* For these modes we will consider the ID that's in the record.
|
||||
|
@@ -20,7 +20,7 @@ import {
|
||||
useTranslate,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||
const translate = useTranslate();
|
||||
@@ -43,6 +43,7 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||
<TextInput
|
||||
source="body"
|
||||
label="resources.servernotices.fields.body"
|
||||
fullWidth
|
||||
multiline
|
||||
rows="4"
|
||||
resettable
|
||||
@@ -63,10 +64,6 @@ export const ServerNoticeButton = () => {
|
||||
const handleDialogOpen = () => setOpen(true);
|
||||
const handleDialogClose = () => setOpen(false);
|
||||
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSend = (values: Partial<RaRecord>) => {
|
||||
create(
|
||||
"servernotices",
|
||||
@@ -103,26 +100,28 @@ export const ServerNoticeBulkButton = () => {
|
||||
const unselectAllUsers = useUnselectAll("users");
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
const { mutate: sendNotices, isPending } = useMutation({
|
||||
mutationFn: (data) =>
|
||||
const { mutate: sendNotices, isLoading } = useMutation(
|
||||
data =>
|
||||
dataProvider.createMany("servernotices", {
|
||||
ids: selectedIds,
|
||||
data: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify("resources.servernotices.action.send_success");
|
||||
unselectAllUsers();
|
||||
closeDialog();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.servernotices.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
});
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.servernotices.action.send_success");
|
||||
unselectAllUsers();
|
||||
closeDialog();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.servernotices.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
|
||||
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
|
||||
<MessageIcon />
|
||||
</Button>
|
||||
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
|
||||
|
6
src/components/error.ts
Normal file
6
src/components/error.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type MatrixError = {
|
||||
errcode: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`;
|
@@ -28,7 +28,7 @@ import {
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation } from "react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { dateParser } from "./date";
|
||||
@@ -55,12 +55,14 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||
<DateTimeInput
|
||||
fullWidth
|
||||
source="before_ts"
|
||||
label="delete_media.fields.before_ts"
|
||||
defaultValue={0}
|
||||
parse={dateParser}
|
||||
/>
|
||||
<NumberInput
|
||||
fullWidth
|
||||
source="size_gt"
|
||||
label="delete_media.fields.size_gt"
|
||||
defaultValue={0}
|
||||
@@ -68,6 +70,7 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||
step={1024}
|
||||
/>
|
||||
<BooleanInput
|
||||
fullWidth
|
||||
source="keep_profiles"
|
||||
label="delete_media.fields.keep_profiles"
|
||||
defaultValue={true}
|
||||
@@ -83,18 +86,20 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||
const { mutate: deleteMedia, isPending } = useMutation({
|
||||
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||
onSuccess: () => {
|
||||
notify("delete_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => {
|
||||
notify("delete_media.action.send_failure", {
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutate: deleteMedia, isLoading } = useMutation(
|
||||
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("delete_media.action.send_success");
|
||||
closeDialog();
|
||||
},
|
||||
onError: () => {
|
||||
notify("delete_media.action.send_failure", {
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const openDialog = () => setOpen(true);
|
||||
const closeDialog = () => setOpen(false);
|
||||
@@ -105,7 +110,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
||||
{...props}
|
||||
label="delete_media.action.send"
|
||||
onClick={openDialog}
|
||||
disabled={isPending}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const de: SynapseTranslationMessages = {
|
||||
...formalGermanMessages,
|
||||
ra: {
|
||||
...formalGermanMessages.ra,
|
||||
navigation: {
|
||||
...formalGermanMessages.ra.navigation,
|
||||
no_filtered_results: "Keine Ergebnisse",
|
||||
clear_filters: "Alle Filter entfernen",
|
||||
},
|
||||
},
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Heimserver URL",
|
||||
@@ -133,6 +125,7 @@ const de: SynapseTranslationMessages = {
|
||||
erased: "Gelöscht",
|
||||
guests: "Zeige Gäste",
|
||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||
show_locked: "Zeige gesperrte Benutzer",
|
||||
user_id: "Suche Benutzer",
|
||||
displayname: "Anzeigename",
|
||||
password: "Passwort",
|
||||
@@ -150,9 +143,11 @@ const de: SynapseTranslationMessages = {
|
||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
||||
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt",
|
||||
},
|
||||
action: {
|
||||
erase: "Lösche Benutzerdaten",
|
||||
erase_avatar: "Avatar löschen"
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@@ -205,6 +200,11 @@ const de: SynapseTranslationMessages = {
|
||||
title: "Raum löschen",
|
||||
content:
|
||||
"Sind Sie sicher dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
|
||||
fields: {
|
||||
block: "Benutzer blockieren und daran hindern, dem Raum beizutreten",
|
||||
},
|
||||
success: "Raum/Räume erfolgreich gelöscht.",
|
||||
failure: "Der/die Raum/Räume konnten nicht gelöscht werden.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -142,9 +142,11 @@ const en: SynapseTranslationMessages = {
|
||||
password: "Changing password will log user out of all sessions.",
|
||||
deactivate: "You must provide a password to re-activate an account.",
|
||||
erase: "Mark the user as GDPR-erased",
|
||||
erase_admin_error: "Deleting own user is not allowed.",
|
||||
},
|
||||
action: {
|
||||
erase: "Erase user data",
|
||||
erase_avatar: "Erase avatar"
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@@ -197,6 +199,11 @@ const en: SynapseTranslationMessages = {
|
||||
title: "Delete room",
|
||||
content:
|
||||
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
|
||||
fields: {
|
||||
block: "Block and prevent users from joining the room",
|
||||
},
|
||||
success: "Room/s successfully deleted.",
|
||||
failure: "The room/s could not be deleted.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -120,6 +120,7 @@ const fa: SynapseTranslationMessages = {
|
||||
deactivated: "غیرفعال",
|
||||
guests: "نمایش مهمانان",
|
||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||
show_locked: "نمایش کاربران قفل شده",
|
||||
user_id: "جستجوی کاربر",
|
||||
displayname: "نام نمایشی",
|
||||
password: "رمز عبور",
|
||||
|
@@ -124,6 +124,7 @@ const fr: SynapseTranslationMessages = {
|
||||
deactivated: "Désactivé",
|
||||
guests: "Afficher les visiteurs",
|
||||
show_deactivated: "Afficher les utilisateurs désactivés",
|
||||
show_locked: "Afficher les utilisateurs verrouillés",
|
||||
user_id: "Rechercher un utilisateur",
|
||||
displayname: "Nom d'affichage",
|
||||
password: "Mot de passe",
|
||||
@@ -139,9 +140,11 @@ const fr: SynapseTranslationMessages = {
|
||||
helper: {
|
||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
|
||||
},
|
||||
action: {
|
||||
erase: "Effacer les données de l'utilisateur",
|
||||
erase_avatar: "Effacer l'avatar",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@@ -194,6 +197,11 @@ const fr: SynapseTranslationMessages = {
|
||||
title: "Supprimer le salon",
|
||||
content:
|
||||
"Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !",
|
||||
fields: {
|
||||
block: "Bloquer et empêcher les utilisateurs de rejoindre la salle",
|
||||
},
|
||||
success: "Salle/s supprimées avec succès.",
|
||||
failure: "La/les salle/s n'ont pas pu être supprimées.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
7
src/i18n/index.d.ts
vendored
7
src/i18n/index.d.ts
vendored
@@ -138,9 +138,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
password?: string;
|
||||
deactivate: string;
|
||||
erase: string;
|
||||
erase_admin_error: string;
|
||||
};
|
||||
action: {
|
||||
erase: string;
|
||||
erase_avatar: string;
|
||||
};
|
||||
};
|
||||
rooms: {
|
||||
@@ -191,6 +193,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
erase: {
|
||||
title: string;
|
||||
content: string;
|
||||
fields: {
|
||||
block: string;
|
||||
},
|
||||
success: string;
|
||||
failure: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@@ -121,6 +121,7 @@ const it: SynapseTranslationMessages = {
|
||||
deactivated: "Disattivato",
|
||||
guests: "Mostra gli ospiti",
|
||||
show_deactivated: "Mostra gli utenti disattivati",
|
||||
show_locked: "Mostra gli utenti bloccati",
|
||||
user_id: "Cerca utente",
|
||||
displayname: "Nickname",
|
||||
password: "Password",
|
||||
@@ -141,6 +142,7 @@ const it: SynapseTranslationMessages = {
|
||||
},
|
||||
action: {
|
||||
erase: "Cancella i dati dell'utente",
|
||||
erase_admin_error: "Non è consentito eliminare il proprio utente.",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const ru: SynapseTranslationMessages = {
|
||||
...russianMessages,
|
||||
ra: {
|
||||
...russianMessages.ra,
|
||||
navigation: {
|
||||
...russianMessages.ra.navigation,
|
||||
no_filtered_results: "Нет результатов",
|
||||
clear_filters: "Все фильтры сбросить",
|
||||
},
|
||||
},
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "Адрес домашнего сервера",
|
||||
@@ -141,6 +133,7 @@ const ru: SynapseTranslationMessages = {
|
||||
erased: "Удалён",
|
||||
guests: "Показывать гостей",
|
||||
show_deactivated: "Показывать деактивированных",
|
||||
show_locked: "Показывать заблокированных",
|
||||
user_id: "Поиск пользователя",
|
||||
displayname: "Отображаемое имя",
|
||||
password: "Пароль",
|
||||
@@ -158,9 +151,11 @@ const ru: SynapseTranslationMessages = {
|
||||
password: "Смена пароля завершит все сессии пользователя.",
|
||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||
},
|
||||
action: {
|
||||
erase: "Удалить данные пользователя",
|
||||
erase_avatar: "Удалить аватар",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
@@ -216,6 +211,11 @@ const ru: SynapseTranslationMessages = {
|
||||
title: "Удалить комнату",
|
||||
content:
|
||||
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
||||
fields: {
|
||||
block: "Заблокировать и запретить пользователям присоединяться к комнате",
|
||||
},
|
||||
success: "Комната/ы успешно удалены",
|
||||
failure: "Комната/ы не могут быть удалены.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
||||
|
||||
const zh: SynapseTranslationMessages = {
|
||||
...chineseMessages,
|
||||
ra: {
|
||||
...chineseMessages.ra,
|
||||
navigation: {
|
||||
...chineseMessages.ra.navigation,
|
||||
no_filtered_results: "没有结果",
|
||||
clear_filters: "清除所有过滤器",
|
||||
},
|
||||
},
|
||||
synapseadmin: {
|
||||
auth: {
|
||||
base_url: "服务器 URL",
|
||||
@@ -128,6 +120,7 @@ const zh: SynapseTranslationMessages = {
|
||||
deactivated: "被禁用",
|
||||
guests: "显示访客",
|
||||
show_deactivated: "显示被禁用的账户",
|
||||
show_locked: "显示被锁定的账户",
|
||||
user_id: "搜索用户",
|
||||
displayname: "显示名字",
|
||||
password: "密码",
|
||||
@@ -142,9 +135,11 @@ const zh: SynapseTranslationMessages = {
|
||||
helper: {
|
||||
deactivate: "您必须提供一串密码来激活账户。",
|
||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||
erase_admin_error: "不允许删除自己的用户",
|
||||
},
|
||||
action: {
|
||||
erase: "抹除用户信息",
|
||||
erase_avatar: "抹掉头像",
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
|
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { AppContext } from "./AppContext";
|
||||
|
||||
fetch(`${import.meta.env.BASE_URL}/config.json`)
|
||||
fetch("config.json")
|
||||
.then(res => res.json())
|
||||
.then(props =>
|
||||
createRoot(document.getElementById("root")).render(
|
||||
|
@@ -168,7 +168,9 @@ const LoginPage = () => {
|
||||
const [matrixVersions, setMatrixVersions] = useState("");
|
||||
|
||||
const handleUsernameChange = () => {
|
||||
if (formData.base_url || allowSingleBaseUrl) return;
|
||||
if (formData.base_url || allowSingleBaseUrl) {
|
||||
return;
|
||||
}
|
||||
// check if username is a full qualified userId then set base_url accordingly
|
||||
const domain = splitMxid(formData.username)?.domain;
|
||||
if (domain) {
|
||||
@@ -180,6 +182,9 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.base_url) {
|
||||
form.setValue("base_url", "");
|
||||
}
|
||||
if (formData.base_url === "" && allowMultipleBaseUrls) {
|
||||
form.setValue("base_url", restrictBaseUrl[0]);
|
||||
}
|
||||
@@ -217,6 +222,7 @@ const LoginPage = () => {
|
||||
disabled={loading || !supportPassAuth}
|
||||
onBlur={handleUsernameChange}
|
||||
resettable
|
||||
fullWidth
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
@@ -228,6 +234,7 @@ const LoginPage = () => {
|
||||
autoComplete="current-password"
|
||||
disabled={loading || !supportPassAuth}
|
||||
resettable
|
||||
fullWidth
|
||||
validate={required()}
|
||||
/>
|
||||
</Box>
|
||||
@@ -240,6 +247,7 @@ const LoginPage = () => {
|
||||
disabled={loading}
|
||||
readOnly={allowSingleBaseUrl}
|
||||
resettable={allowAnyBaseUrl}
|
||||
fullWidth
|
||||
validate={[required(), validateBaseUrl]}
|
||||
>
|
||||
{allowMultipleBaseUrls &&
|
||||
|
@@ -1,11 +1,9 @@
|
||||
import { get } from "lodash";
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
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 { blue } from "@mui/material/colors";
|
||||
import {
|
||||
Button,
|
||||
Datagrid,
|
||||
@@ -33,10 +31,17 @@ import {
|
||||
} from "react-admin";
|
||||
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import { lighten, useTheme } from '@mui/material';
|
||||
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 = () => {
|
||||
@@ -101,16 +106,6 @@ const RetryDateField = (props: DateFieldProps) => {
|
||||
};
|
||||
|
||||
export const DestinationList = (props: ListProps) => {
|
||||
const { palette: { error, mode }, } = useTheme();
|
||||
const destinationRowSx = (record: RaRecord) => ({
|
||||
backgroundColor: record.retry_last_ts > 0 ? lighten(error[mode], 0.5) : undefined,
|
||||
"& > td": mode === 'dark' ? {
|
||||
color: record.retry_last_ts > 0 ? "black" : "white",
|
||||
"& > button": {
|
||||
color: blue[700],
|
||||
},
|
||||
} : undefined,
|
||||
});
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
|
@@ -25,7 +25,7 @@ import {
|
||||
useRefresh,
|
||||
useUnselectAll,
|
||||
} from "react-admin";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import AvatarField from "../components/AvatarField";
|
||||
|
||||
@@ -70,25 +70,27 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
|
||||
const refresh = useRefresh();
|
||||
const unselectAllRooms = useUnselectAll("rooms");
|
||||
const dataProvider = useDataProvider();
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: () =>
|
||||
const { mutate, isLoading } = useMutation(
|
||||
() =>
|
||||
dataProvider.createMany("room_directory", {
|
||||
ids: selectedIds,
|
||||
data: {},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify("resources.room_directory.action.send_success");
|
||||
unselectAllRooms();
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.room_directory.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
});
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify("resources.room_directory.action.send_success");
|
||||
unselectAllRooms();
|
||||
refresh();
|
||||
},
|
||||
onError: () =>
|
||||
notify("resources.room_directory.action.send_failure", {
|
||||
type: "error",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
|
||||
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isLoading}>
|
||||
<RoomDirectoryIcon />
|
||||
</Button>
|
||||
);
|
||||
@@ -100,10 +102,6 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
|
||||
const refresh = useRefresh();
|
||||
const [create, { isLoading }] = useCreate();
|
||||
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
create(
|
||||
"room_directory",
|
||||
|
@@ -36,6 +36,7 @@ import {
|
||||
TopToolbar,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
useListContext,
|
||||
} from "react-admin";
|
||||
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
RoomDirectoryPublishButton,
|
||||
} from "./room_directory";
|
||||
import { DATE_FORMAT } from "../components/date";
|
||||
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||
|
||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||
|
||||
@@ -70,8 +72,8 @@ const RoomShowActions = () => {
|
||||
return (
|
||||
<TopToolbar>
|
||||
{publishButton}
|
||||
<DeleteButton
|
||||
mutationMode="pessimistic"
|
||||
<DeleteRoomButton
|
||||
selectedIds={[record.id]}
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
/>
|
||||
@@ -207,17 +209,20 @@ export const RoomShow = (props: ShowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RoomBulkActionButtons = () => (
|
||||
<>
|
||||
<RoomDirectoryBulkPublishButton />
|
||||
<RoomDirectoryBulkUnpublishButton />
|
||||
<BulkDeleteButton
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const RoomBulkActionButtons = () => {
|
||||
const record = useListContext();
|
||||
return (
|
||||
<>
|
||||
<RoomDirectoryBulkPublishButton />
|
||||
<RoomDirectoryBulkUnpublishButton />
|
||||
<DeleteRoomButton
|
||||
selectedIds={record.selectedIds}
|
||||
confirmTitle="resources.rooms.action.erase.title"
|
||||
confirmContent="resources.rooms.action.erase.content"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
||||
|
||||
|
@@ -8,6 +8,8 @@ import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, ownerDocument } from "@mui/material";
|
||||
import {
|
||||
ArrayInput,
|
||||
ArrayField,
|
||||
@@ -42,12 +44,19 @@ import {
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
Pagination,
|
||||
SaveButton,
|
||||
CreateButton,
|
||||
ExportButton,
|
||||
TopToolbar,
|
||||
Toolbar,
|
||||
NumberField,
|
||||
useListContext,
|
||||
useNotify,
|
||||
ToolbarClasses,
|
||||
Identifier,
|
||||
RaRecord,
|
||||
ImageInput,
|
||||
ImageField,
|
||||
} from "react-admin";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -94,16 +103,50 @@ const userFilters = [
|
||||
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
||||
];
|
||||
|
||||
const UserBulkActionButtons = () => (
|
||||
<>
|
||||
<ServerNoticeBulkButton />
|
||||
<BulkDeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean }> = props => {
|
||||
const ownUserIsSelected = props.ownUserIsSelected;
|
||||
const notify = useNotify();
|
||||
const translate = useTranslate();
|
||||
|
||||
const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (ownUserIsSelected) {
|
||||
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
|
||||
};
|
||||
|
||||
const UserBulkActionButtons = () => {
|
||||
const record = useListContext();
|
||||
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
|
||||
const selectedIds = record.selectedIds;
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
const notify = useNotify();
|
||||
const translate = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||
}, [selectedIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerNoticeBulkButton />
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||
<BulkDeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle="resources.users.helper.erase"
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
|
||||
return `/users/${id}`;
|
||||
};
|
||||
|
||||
export const UserList = (props: ListProps) => (
|
||||
<List
|
||||
@@ -114,10 +157,7 @@ export const UserList = (props: ListProps) => (
|
||||
actions={<UserListActions />}
|
||||
pagination={<UserPagination />}
|
||||
>
|
||||
<Datagrid
|
||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
|
||||
bulkActionButtons={<UserBulkActionButtons />}
|
||||
>
|
||||
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
|
||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
|
||||
<TextField source="id" sortBy="name" />
|
||||
<TextField source="displayname" />
|
||||
@@ -142,17 +182,24 @@ const validateAddress = [required(), maxLength(255)];
|
||||
const UserEditActions = () => {
|
||||
const record = useRecordContext();
|
||||
const translate = useTranslate();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
if (record && record.id) {
|
||||
ownUserIsSelected = record.id === ownUserId;
|
||||
}
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
{!record?.deactivated && <ServerNoticeButton />}
|
||||
<DeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle={translate("resources.users.helper.erase", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||
<DeleteButton
|
||||
label="resources.users.action.erase"
|
||||
confirmTitle={translate("resources.users.helper.erase", {
|
||||
smart_count: 1,
|
||||
})}
|
||||
mutationMode="pessimistic"
|
||||
/>
|
||||
</UserPreventSelfDelete>
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
@@ -160,8 +207,8 @@ const UserEditActions = () => {
|
||||
export const UserCreate = (props: CreateProps) => (
|
||||
<Create
|
||||
{...props}
|
||||
redirect={(resource: string | undefined, id: Identifier | undefined) => {
|
||||
return `${resource}/${id}`;
|
||||
redirect={(resource, id, data) => {
|
||||
return `users/${id}`;
|
||||
}}
|
||||
>
|
||||
<SimpleForm>
|
||||
@@ -194,25 +241,67 @@ const UserTitle = () => {
|
||||
{translate("resources.users.name", {
|
||||
smart_count: 1,
|
||||
})}{" "}
|
||||
{record ? `"${record.displayname}"` : ""}
|
||||
{record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const UserEditToolbar = () => {
|
||||
const record = useRecordContext();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
if (record && record.id) {
|
||||
ownUserIsSelected = record.id === ownUserId;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={ToolbarClasses.defaultToolbar}>
|
||||
<Toolbar sx={{ justifyContent: "space-between" }}>
|
||||
<SaveButton />
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||
<DeleteButton />
|
||||
</UserPreventSelfDelete>
|
||||
</Toolbar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UserBooleanInput = props => {
|
||||
const record = useRecordContext();
|
||||
const ownUserId = localStorage.getItem("user_id");
|
||||
let ownUserIsSelected = false;
|
||||
if (record && record.id === ownUserId) {
|
||||
ownUserIsSelected = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||
<BooleanInput {...props} disabled={ownUserIsSelected} />
|
||||
</UserPreventSelfDelete>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserEdit = (props: EditProps) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
||||
<TabbedForm>
|
||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
|
||||
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px" }} />
|
||||
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
|
||||
<ImageInput source="avatar_file" label="resources.users.fields.avatar" accept="image/*">
|
||||
<ImageField source="src" title="Avatar" />
|
||||
</ImageInput>
|
||||
<TextInput source="id" disabled />
|
||||
<TextInput source="displayname" />
|
||||
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||
<BooleanInput source="admin" />
|
||||
<BooleanInput source="locked" />
|
||||
<BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||
<BooleanInput source="erased" disabled />
|
||||
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||
<TextField source="consent_version" />
|
||||
|
@@ -2,6 +2,7 @@ import fetchMock from "jest-fetch-mock";
|
||||
|
||||
import authProvider from "./authProvider";
|
||||
import storage from "../storage";
|
||||
import { HttpError } from "ra-core";
|
||||
|
||||
fetchMock.enableMocks();
|
||||
|
||||
@@ -30,17 +31,7 @@ describe("authProvider", () => {
|
||||
|
||||
expect(ret).toBe(undefined);
|
||||
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
|
||||
body: JSON.stringify({
|
||||
device_id: null,
|
||||
initial_device_display_name: "Synapse Admin",
|
||||
type: "m.login.password",
|
||||
user: "@user:example.com",
|
||||
password: "secret",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: "@user:example.com",
|
||||
}
|
||||
}),
|
||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}',
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
@@ -110,11 +101,11 @@ describe("authProvider", () => {
|
||||
});
|
||||
|
||||
it("should reject if error.status is 401", async () => {
|
||||
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject if error.status is 403", async () => {
|
||||
await expect(authProvider.checkError({ status: 403 })).rejects.toBeUndefined();
|
||||
await expect(authProvider.checkError(new HttpError("test-error", 403, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { AuthProvider, Options, fetchUtils } from "react-admin";
|
||||
import { AuthProvider, HttpError, Options, fetchUtils, useTranslate } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { MatrixError, displayError } from "../components/error";
|
||||
|
||||
const authProvider: AuthProvider = {
|
||||
// called when the user attempts to log in
|
||||
@@ -31,12 +32,11 @@ const authProvider: AuthProvider = {
|
||||
}
|
||||
: {
|
||||
type: "m.login.password",
|
||||
user: username,
|
||||
password: password,
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password: password,
|
||||
}
|
||||
)
|
||||
),
|
||||
@@ -45,13 +45,36 @@ const authProvider: AuthProvider = {
|
||||
// use the base_url from login instead of the well_known entry from the
|
||||
// server, since the admin might want to access the admin API via some
|
||||
// private address
|
||||
if (!base_url) {
|
||||
// there is some kind of bug with base_url being present in the form, but not submitted
|
||||
// ref: https://github.com/etkecc/synapse-admin/issues/14
|
||||
storage.removeItem("base_url")
|
||||
throw new Error("Homeserver URL is required.");
|
||||
}
|
||||
base_url = base_url.replace(/\/+$/g, "");
|
||||
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";
|
||||
|
||||
const { json } = await fetchUtils.fetchJson(login_api_url, options);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchUtils.fetchJson(login_api_url, options);
|
||||
} catch(err) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
return Promise.reject(
|
||||
new HttpError(
|
||||
errMsg,
|
||||
errorStatus,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -78,10 +101,12 @@ const authProvider: AuthProvider = {
|
||||
}
|
||||
},
|
||||
// called when the API returns an error
|
||||
checkError: ({ status }: { status: number }) => {
|
||||
console.log("checkError " + status);
|
||||
checkError: (err: HttpError) => {
|
||||
const errorBody = err.body as MatrixError;
|
||||
const status = err.status;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return Promise.reject();
|
||||
return Promise.reject({message: displayError(errorBody.errcode, status, errorBody.error)});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
@@ -18,7 +18,7 @@ describe("dataProvider", () => {
|
||||
JSON.stringify({
|
||||
users: [
|
||||
{
|
||||
name: "user_id1",
|
||||
name: "@user_id1:provider",
|
||||
password_hash: "password_hash1",
|
||||
is_guest: 0,
|
||||
admin: 0,
|
||||
@@ -27,7 +27,7 @@ describe("dataProvider", () => {
|
||||
displayname: "User One",
|
||||
},
|
||||
{
|
||||
name: "user_id2",
|
||||
name: "@user_id2:provider",
|
||||
password_hash: "password_hash2",
|
||||
is_guest: 0,
|
||||
admin: 1,
|
||||
@@ -47,7 +47,7 @@ describe("dataProvider", () => {
|
||||
filter: { author_id: 12 },
|
||||
});
|
||||
|
||||
expect(users.data[0].id).toEqual("user_id1");
|
||||
expect(users.data[0].id).toEqual("@user_id1:provider");
|
||||
expect(users.total).toEqual(200);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -55,7 +55,7 @@ describe("dataProvider", () => {
|
||||
it("fetches one user", async () => {
|
||||
fetchMock.mockResponseOnce(
|
||||
JSON.stringify({
|
||||
name: "user_id1",
|
||||
name: "@user_id1:provider",
|
||||
password: "user_password",
|
||||
displayname: "User",
|
||||
threepids: [
|
||||
@@ -74,9 +74,9 @@ describe("dataProvider", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const user = await dataProvider.getOne("users", { id: "user_id1" });
|
||||
const user = await dataProvider.getOne("users", { id: "@user_id1:provider" });
|
||||
|
||||
expect(user.data.id).toEqual("user_id1");
|
||||
expect(user.data.id).toEqual("@user_id1:provider");
|
||||
expect(user.data.displayname).toEqual("User");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@@ -1,27 +1,44 @@
|
||||
import { stringify } from "query-string";
|
||||
|
||||
import {
|
||||
DataProvider,
|
||||
DeleteParams,
|
||||
HttpError,
|
||||
Identifier,
|
||||
Options,
|
||||
PaginationPayload,
|
||||
RaRecord,
|
||||
SortPayload,
|
||||
fetchUtils
|
||||
UpdateParams,
|
||||
fetchUtils,
|
||||
withLifecycleCallbacks,
|
||||
} from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
import { returnMXID } from "./synapse";
|
||||
import { MatrixError, displayError } from "../components/error";
|
||||
|
||||
// Adds the access token to all requests
|
||||
const jsonClient = (url: string, options: Options = {}) => {
|
||||
const jsonClient = async (url: string, options: Options = {}) => {
|
||||
const token = storage.getItem("access_token");
|
||||
console.log("httpClient " + url);
|
||||
if (token != null) {
|
||||
if (token !== null) {
|
||||
options.user = {
|
||||
authenticated: true,
|
||||
token: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
return fetchUtils.fetchJson(url, options);
|
||||
try {
|
||||
const response = await fetchUtils.fetchJson(url, options);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const error = err as HttpError;
|
||||
const errorStatus = error.status;
|
||||
const errorBody = error.body as MatrixError;
|
||||
const errMsg = !!errorBody?.errcode
|
||||
? displayError(errorBody.errcode, errorStatus, errorBody.error)
|
||||
: displayError("M_INVALID", errorStatus, error.message);
|
||||
|
||||
return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
|
||||
}
|
||||
};
|
||||
|
||||
const mxcUrlToHttp = (mxcUrl: string) => {
|
||||
@@ -221,8 +238,19 @@ export interface DeleteMediaResult {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UploadMediaParams {
|
||||
file: File;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
content_uri: string;
|
||||
}
|
||||
|
||||
export interface SynapseDataProvider extends DataProvider {
|
||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
@@ -230,7 +258,7 @@ const resourceMap = {
|
||||
path: "/_synapse/admin/v2/users",
|
||||
map: (u: User) => ({
|
||||
...u,
|
||||
id: u.name,
|
||||
id: returnMXID(u.name),
|
||||
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
|
||||
is_guest: !!u.is_guest,
|
||||
admin: !!u.admin,
|
||||
@@ -241,12 +269,12 @@ const resourceMap = {
|
||||
data: "users",
|
||||
total: json => json.total,
|
||||
create: (data: RaRecord) => ({
|
||||
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`,
|
||||
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(returnMXID(data.id))}`,
|
||||
body: data,
|
||||
method: "PUT",
|
||||
}),
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
|
||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`,
|
||||
body: { erase: true },
|
||||
method: "POST",
|
||||
}),
|
||||
@@ -266,7 +294,7 @@ const resourceMap = {
|
||||
total: json => json.total_rooms,
|
||||
delete: (params: DeleteParams) => ({
|
||||
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
|
||||
body: { block: false },
|
||||
body: { block: params.meta?.block ?? false },
|
||||
}),
|
||||
},
|
||||
reports: {
|
||||
@@ -345,7 +373,7 @@ const resourceMap = {
|
||||
id: um.media_id,
|
||||
}),
|
||||
reference: (id: Identifier) => ({
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
|
||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`,
|
||||
}),
|
||||
data: "media",
|
||||
total: json => json.total,
|
||||
@@ -380,7 +408,7 @@ const resourceMap = {
|
||||
create: (data: RaServerNotice) => ({
|
||||
endpoint: "/_synapse/admin/v1/send_server_notice",
|
||||
body: {
|
||||
user_id: data.id,
|
||||
user_id: returnMXID(data.id),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: data.body,
|
||||
@@ -393,7 +421,7 @@ const resourceMap = {
|
||||
path: "/_synapse/admin/v1/statistics/users/media",
|
||||
map: (usms: UserMediaStatistic) => ({
|
||||
...usms,
|
||||
id: usms.user_id,
|
||||
id: returnMXID(usms.user_id),
|
||||
}),
|
||||
data: "users",
|
||||
total: json => json.total,
|
||||
@@ -495,12 +523,12 @@ function getSearchOrder(order: "ASC" | "DESC") {
|
||||
}
|
||||
}
|
||||
|
||||
const dataProvider: SynapseDataProvider = {
|
||||
const baseDataProvider: SynapseDataProvider = {
|
||||
getList: async (resource, params) => {
|
||||
console.log("getList " + resource);
|
||||
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
||||
const { page, perPage } = params.pagination as PaginationPayload;
|
||||
const { field, order } = params.sort as SortPayload;
|
||||
const { page, perPage } = params.pagination;
|
||||
const { field, order } = params.sort;
|
||||
const from = (page - 1) * perPage;
|
||||
const query = {
|
||||
from: from,
|
||||
@@ -737,6 +765,46 @@ const dataProvider: SynapseDataProvider = {
|
||||
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
||||
return json as DeleteMediaResult;
|
||||
},
|
||||
|
||||
uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
|
||||
const base_url = storage.getItem("base_url");
|
||||
const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
|
||||
|
||||
const { json } = await jsonClient(`${uploadMediaURL}?filename=${filename}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": content_type,
|
||||
}) as Headers,
|
||||
});
|
||||
return json as UploadMediaResult;
|
||||
},
|
||||
};
|
||||
|
||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
{
|
||||
resource: "users",
|
||||
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
|
||||
const avatarFile = params.data.avatar_file?.rawFile;
|
||||
const avatarErase = params.data.avatar_erase;
|
||||
|
||||
if (avatarErase) {
|
||||
params.data.avatar_url = "";
|
||||
return params;
|
||||
}
|
||||
|
||||
if (avatarFile instanceof File) {
|
||||
const reponse = await dataProvider.uploadMedia({
|
||||
file: avatarFile,
|
||||
filename: params.data.avatar_file.title,
|
||||
content_type: params.data.avatar_file.rawFile.type,
|
||||
});
|
||||
params.data.avatar_url = reponse.content_uri;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default dataProvider;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { fetchUtils } from "react-admin";
|
||||
import { Identifier, fetchUtils } from "react-admin";
|
||||
|
||||
import storage from "../storage";
|
||||
|
||||
@@ -72,6 +72,26 @@ export function generateRandomMxId(): string {
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full MXID from an arbitrary input
|
||||
* @param input the input string
|
||||
* @returns full MXID as string
|
||||
*/
|
||||
export function returnMXID(input: string | Identifier): string {
|
||||
const homeserver = storage.getItem("home_server");
|
||||
|
||||
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
|
||||
const mxidPattern = /^@[^@:]+:[^@:]+$/;
|
||||
if (typeof input === 'string' && mxidPattern.test(input)) {
|
||||
return input; // Already a valid MXID
|
||||
}
|
||||
|
||||
// If input is not a valid MXID, assume it's a localpart and construct the MXID
|
||||
const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input;
|
||||
return `@${localpart}:${homeserver}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a random user password
|
||||
* @returns a new random password as string
|
||||
|
191
testdata/synapse/homeserver.yaml
vendored
Normal file
191
testdata/synapse/homeserver.yaml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
account_threepid_delegates:
|
||||
msisdn: ''
|
||||
alias_creation_rules:
|
||||
- action: allow
|
||||
alias: '*'
|
||||
room_id: '*'
|
||||
user_id: '*'
|
||||
allow_guest_access: false
|
||||
allow_public_rooms_over_federation: true
|
||||
allow_public_rooms_without_auth: true
|
||||
app_service_config_files: []
|
||||
autocreate_auto_join_rooms: true
|
||||
background_updates: null
|
||||
caches:
|
||||
global_factor: 0.5
|
||||
per_cache_factors: null
|
||||
cas_config: null
|
||||
database:
|
||||
args:
|
||||
cp_max: 10
|
||||
cp_min: 5
|
||||
database: synapse
|
||||
host: postgres
|
||||
password: synapse
|
||||
port: 5432
|
||||
user: synapse
|
||||
name: psycopg2
|
||||
txn_limit: 0
|
||||
default_room_version: '10'
|
||||
disable_msisdn_registration: true
|
||||
email:
|
||||
enable_media_repo: true
|
||||
enable_metrics: false
|
||||
enable_registration: false
|
||||
enable_registration_captcha: false
|
||||
enable_registration_without_verification: false
|
||||
enable_room_list_search: true
|
||||
encryption_enabled_by_default_for_room_type: 'off'
|
||||
event_cache_size: 100K
|
||||
federation_rr_transactions_per_room_per_second: 50
|
||||
form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi
|
||||
include_profile_data_on_invite: true
|
||||
instance_map: {}
|
||||
limit_profile_requests_to_users_who_share_rooms: false
|
||||
limit_remote_rooms: null
|
||||
listeners:
|
||||
- bind_addresses:
|
||||
- '::'
|
||||
port: 8008
|
||||
resources:
|
||||
- compress: false
|
||||
names:
|
||||
- client
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
log_config: /config/synapse.log.config
|
||||
macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE
|
||||
manhole_settings: null
|
||||
max_spider_size: 10M
|
||||
max_upload_size: 1024M
|
||||
media_retention:
|
||||
local_media_lifetime: 30d
|
||||
remote_media_lifetime: 7d
|
||||
media_storage_providers: []
|
||||
media_store_path: /media-store
|
||||
metrics_flags: null
|
||||
modules: []
|
||||
oembed: null
|
||||
oidc_providers: null
|
||||
old_signing_keys: null
|
||||
opentracing: null
|
||||
password_config:
|
||||
enabled: true
|
||||
localdb_enabled: true
|
||||
pepper: zfvnYqxe3GTkdJ9BlfZiAqy2zMsjOg02uBTEiWLp2hjQGqlDw33pTSTplE6HoWlF
|
||||
policy: null
|
||||
pid_file: /homeserver.pid
|
||||
presence:
|
||||
enabled: true
|
||||
public_baseurl: http://synapse:8008/
|
||||
push:
|
||||
include_content: true
|
||||
rc_admin_redaction:
|
||||
burst_count: 50
|
||||
per_second: 1
|
||||
rc_federation:
|
||||
concurrent: 3
|
||||
reject_limit: 50
|
||||
sleep_delay: 500
|
||||
sleep_limit: 10
|
||||
window_size: 1000
|
||||
rc_invites:
|
||||
per_issuer:
|
||||
burst_count: 10
|
||||
per_second: 0.3
|
||||
per_room:
|
||||
burst_count: 10
|
||||
per_second: 0.3
|
||||
per_user:
|
||||
burst_count: 5
|
||||
per_second: 0.003
|
||||
rc_joins:
|
||||
local:
|
||||
burst_count: 10
|
||||
per_second: 0.1
|
||||
remote:
|
||||
burst_count: 10
|
||||
per_second: 0.01
|
||||
rc_login:
|
||||
account:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
address:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
failed_attempts:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
rc_message:
|
||||
burst_count: 10
|
||||
per_second: 0.2
|
||||
rc_registration:
|
||||
burst_count: 3
|
||||
per_second: 0.17
|
||||
recaptcha_private_key: ''
|
||||
recaptcha_public_key: ''
|
||||
redaction_retention_period: 5m
|
||||
redis:
|
||||
enabled: false
|
||||
host: null
|
||||
password: null
|
||||
port: 6379
|
||||
registration_requires_token: false
|
||||
registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9
|
||||
report_stats: false
|
||||
require_auth_for_profile_requests: false
|
||||
retention:
|
||||
enabled: true
|
||||
purge_jobs:
|
||||
- interval: 12h
|
||||
room_list_publication_rules:
|
||||
- action: allow
|
||||
alias: '*'
|
||||
room_id: '*'
|
||||
user_id: '*'
|
||||
room_prejoin_state: null
|
||||
saml2_config:
|
||||
sp_config: null
|
||||
user_mapping_provider:
|
||||
config: null
|
||||
server_name: synapse
|
||||
signing_key_path: /config/synapse.signing.key
|
||||
spam_checker: []
|
||||
sso: null
|
||||
stats: null
|
||||
stream_writers: {}
|
||||
templates: null
|
||||
tls_certificate_path: null
|
||||
tls_private_key_path: null
|
||||
trusted_key_servers:
|
||||
- server_name: matrix.org
|
||||
turn_allow_guests: false
|
||||
ui_auth: null
|
||||
url_preview_accept_language:
|
||||
- en-US
|
||||
- en
|
||||
url_preview_enabled: true
|
||||
url_preview_ip_range_blacklist:
|
||||
- 127.0.0.0/8
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 100.64.0.0/10
|
||||
- 192.0.0.0/24
|
||||
- 169.254.0.0/16
|
||||
- 192.88.99.0/24
|
||||
- 198.18.0.0/15
|
||||
- 192.0.2.0/24
|
||||
- 198.51.100.0/24
|
||||
- 203.0.113.0/24
|
||||
- 224.0.0.0/4
|
||||
- ::1/128
|
||||
- fe80::/10
|
||||
- fc00::/7
|
||||
- 2001:db8::/32
|
||||
- ff00::/8
|
||||
- fec0::/10
|
||||
user_directory: null
|
||||
user_ips_max_age: 5m
|
||||
|
28
testdata/synapse/synapse.log.config
vendored
Normal file
28
testdata/synapse/synapse.log.config
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
version: 1
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
filters:
|
||||
context:
|
||||
(): synapse.util.logcontext.LoggingContextFilter
|
||||
request: ""
|
||||
handlers:
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
filters: [context]
|
||||
loggers:
|
||||
synapse:
|
||||
level: INFO
|
||||
shared_secret_authenticator:
|
||||
level: INFO
|
||||
rest_auth_provider:
|
||||
level: INFO
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: INFO
|
||||
root:
|
||||
level: INFO
|
||||
handlers: [console]
|
||||
|
1
testdata/synapse/synapse.signing.key
vendored
Normal file
1
testdata/synapse/synapse.signing.key
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo
|
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
vitePluginVersionMark({
|
||||
command: "git describe --tags",
|
||||
command: "git describe --tags || git rev-parse --short HEAD",
|
||||
ifMeta: true,
|
||||
ifLog: true,
|
||||
ifGlobal: true,
|
||||
|
Reference in New Issue
Block a user