Compare commits

59 Commits

Author SHA1 Message Date
Borislav Pantaleev
48d933e028 Upgrade react-admin to version 5 (#40)
* wip on ra upgrade

* Finish upgrade

* fix package.json

* pr fixes

* more pr fixes

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

* some fixes

* update readme

* update readme

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

* Fix README

* Remove mutationMode from Edit

* remove log

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

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

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

* update readme
2024-09-17 14:02:41 +03:00
Aine
66c706532a Fix required fields check on Bulk registration CSV upload, fixes #29 (#32) 2024-09-17 13:17:07 +03:00
Aine
332e98a095 add dev stack 2024-09-17 12:54:29 +03:00
Aine
f639a1c9ff rename release artifact file 2024-09-14 21:36:03 +03:00
Aine
8282bb0926 add github release CI step 2024-09-14 21:32:36 +03:00
Aine
264d2ff59d add on-tags CI build 2024-09-14 21:14:22 +03:00
Aine
90a6c7d8c2 switch to tag-based build 2024-09-14 21:09:58 +03:00
Borislav Pantaleev
7de9166648 Add UI option to block deleted rooms from being rejoined (#26)
Add UI option to block deleted rooms from being rejoined
This is almost a copy of https://github.com/Awesome-Technologies/synapse-admin/pull/166 PR,
authored by @jkanefendt
2024-09-14 11:03:51 +03:00
Borislav Pantaleev
0bf3440fc8 Federation page improvements for dark mode and last_entry without a date (#19)
Federation page improvements
This is almost a copy of https://github.com/Awesome-Technologies/synapse-admin/pull/583 PR,
authored by @rkfg
2024-09-06 00:23:23 +03:00
Aine
cceae77529 fix ci 2024-09-06 00:20:37 +03:00
Aine
1474b46ff0 Put version into the manifest.json on CI builds (Docker, CDN, etc.)
This commit fixes the https://github.com/Awesome-Technologies/synapse-admin/issues/507 issue.
While it's a mere workaround, it may help until the
https://github.com/Awesome-Technologies/synapse-admin/issues/507#issuecomment-2063845882 is implemented
2024-09-06 00:15:00 +03:00
Aine
01e3947b22 add missing translations 2024-09-05 23:52:27 +03:00
Borislav Pantaleev
e093bd8625 Fix base_url being undefined on unsuccessful login (#18)
* Fix base_url being undefined on unsuccessful login

* update readme
2024-09-05 23:23:17 +03:00
Borislav Pantaleev
390aab5ce7 Display actual synapse errors (#17)
* Display actual synapse errors

* Show actual errors from dataProvider requests

* update README
2024-09-05 21:39:39 +03:00
Aine
fb1a04971b [skip ci] add justfile 2024-09-05 09:50:06 +03:00
Borislav Pantaleev
eff17a0929 Fix redirect URL after user creation (#16)
* Fix redirect URL after user creation

* increment version; update readme
2024-09-05 09:02:46 +03:00
Aine
8eaaaa50ec cleanup .dockerignore 2024-09-03 16:07:43 +03:00
Aine
85b305d117 ensure the latest version is checked out 2024-09-03 16:01:41 +03:00
Aine
4cbea6ffb6 enable cdn 2024-09-03 15:55:29 +03:00
Aine
1967546ae4 enable docker action to see how it goes 2024-09-03 15:50:11 +03:00
Aine
c9a3294852 use different path 2024-09-03 15:44:50 +03:00
Aine
f1839387e2 fix ci 2024-09-03 15:41:38 +03:00
Aine
c8246a1d19 try ci artifacts 2024-09-03 15:40:23 +03:00
Aine
3c6259cc88 Revert "switch from nginx to SWS for serving the app (docker image)", as it causes issues with CDN deploy - workflow
should be adjusted first

This reverts commit 317df5af0f.
2024-09-03 14:04:32 +03:00
Aine
317df5af0f switch from nginx to SWS for serving the app (docker image) 2024-09-03 14:02:35 +03:00
Aine
2142770a5b Fix footer overlapping content
This is almost a copy of https://github.com/Awesome-Technologies/synapse-admin/issues/574#issue-2406841809 suggestion,
authored by @ll-SKY-ll
2024-09-03 13:46:31 +03:00
Aine
51297b49fc v0.10.3-etke5 2024-09-03 11:50:14 +03:00
Aine
311cc2a1f4 Merge pull request #9 from etkecc/fix-user-names-in-header
Fix user's display name in header on user's page
2024-09-03 11:48:43 +03:00
Aine
6bc760a6fa Fix user's display name in header on user's page
For example, erased users don't have display name, so header was `User "null"`.
With this change, if there is no display name, the MXID will be show, e.g. `User "@user:example.com"`
2024-09-03 11:45:47 +03:00
Aine
50c96cfd77 Add ability to toggle whether to show locked users
This is almost copy of https://github.com/Awesome-Technologies/synapse-admin/pull/573 PR,
authored by @huw1990
2024-09-03 11:09:46 +03:00
Aine
7747dc7f28 Add identifier when authorizing with password
This PR is almost copy of https://github.com/Awesome-Technologies/synapse-admin/pull/601 PR,
authored by @dklimpel
2024-09-03 10:42:45 +03:00
Aine
678867e981 v0.10.3-etke2 2024-09-03 07:12:36 +03:00
Aine
4a4fae104e Merge pull request #8 from etkecc/fix-users-edit-tab
Fix user's Edit action defualt Tab not being shown
2024-09-03 07:09:35 +03:00
Borislav Pantaleev
265b5157af Fix user's Edit action defualt Tab not being shown 2024-09-03 00:53:29 +03:00
Aine
b5b593945d remove configure steps 2024-08-31 21:12:06 +03:00
Aine
15c8a30c92 fix ci 2024-08-31 21:04:39 +03:00
Aine
40c6eb0b95 fix ci 2024-08-31 20:58:07 +03:00
Aine
4c4e3a07f6 fix ci 2024-08-31 20:51:47 +03:00
Aine
35322fa01d fix ci 2024-08-31 20:35:02 +03:00
Aine
88e88fb6b9 add CDN 2024-08-31 20:28:14 +03:00
Aine
7016e5a349 Update CONTRIBUTING.md 2024-08-31 16:17:28 +03:00
Aine
c7e275d4ec Create CONTRIBUTING.md 2024-08-31 16:16:22 +03:00
Aine
b1d3340fce Merge pull request #7 from etkecc/dependabot/npm_and_yarn/micromatch-4.0.8
Bump micromatch from 4.0.5 to 4.0.8
2024-08-31 15:27:02 +03:00
dependabot[bot]
2ae5930578 Bump micromatch from 4.0.5 to 4.0.8
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-31 12:25:16 +00:00
Aine
9dd6940383 Merge pull request #6 from etkecc/fix-ci
update links, allow working without tags
2024-08-31 15:15:27 +03:00
Aine
e2194f5c49 update links, allow working without tags 2024-08-31 15:14:44 +03:00
Aine
e6dcbcb052 Merge pull request #5 from etkecc/fix-ci
set fetch-depth
2024-08-31 15:00:40 +03:00
Aine
5acf3042c3 set fetch-depth 2024-08-31 15:00:10 +03:00
Aine
7286abaaae Merge pull request #4 from etkecc/fix-ci
add checkout
2024-08-31 14:57:40 +03:00
Aine
0d5e95fa7c add checkout 2024-08-31 14:57:07 +03:00
Aine
c4b54c40fb Merge pull request #3 from etkecc/fix-ci
fix ci
2024-08-31 14:55:58 +03:00
Aine
ec0b980e06 fix ci 2024-08-31 14:54:52 +03:00
Aine
94ccd3ad36 Merge pull request #2 from etkecc/add-ci
update CI and readme
2024-08-31 14:49:17 +03:00
Aine
49d67f9130 update CI and readme 2024-08-31 14:47:33 +03:00
Aine
2bb846734e Merge pull request #1 from etkecc/prevent-self-delete
Prevent self user delete
2024-08-31 09:33:00 +03:00
Borislav Pantaleev
056d9c6b4c Prevent self user delete 2024-08-31 01:00:29 +03:00
50 changed files with 6866 additions and 8843 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
liberapay: etkecc

View File

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

View File

@@ -1,23 +0,0 @@
name: build-test
on:
push:
branches: ["master"]
pull_request:
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

View File

@@ -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

View File

@@ -1,29 +0,0 @@
name: Build and Deploy Edge version to GH Pages
on:
workflow_dispatch:
push:
branches:
- main
- master
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v4
with:
fetch-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.6.3
with:
branch: gh-pages
folder: dist

View File

@@ -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@c062e08bd532815e2082a85e87e3ef29c3e6d191
with:
files: dist/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

119
.github/workflows/workflow.yml vendored Normal file
View 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
View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -1,14 +1,52 @@
[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
# Synapse admin ui
# Synapse Admin UI [![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
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)
* [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
_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
View File

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

View File

@@ -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

View File

@@ -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
View 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

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ const App = () => (
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
darkTheme={{ palette: { mode: "dark" } }}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />

View File

@@ -0,0 +1,104 @@
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
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;

View File

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

View File

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

6
src/components/error.ts Normal file
View File

@@ -0,0 +1,6 @@
export type MatrixError = {
errcode: string;
error: string;
}
export const displayError = (errcode: string, status: number, message: string) => `${errcode} (${status}): ${message}`;

View File

@@ -28,7 +28,7 @@ import {
useRefresh,
useTranslate,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { dateParser } from "./date";
@@ -55,14 +55,12 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput
fullWidth
source="before_ts"
label="delete_media.fields.before_ts"
defaultValue={0}
parse={dateParser}
/>
<NumberInput
fullWidth
source="size_gt"
label="delete_media.fields.size_gt"
defaultValue={0}
@@ -70,7 +68,6 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
step={1024}
/>
<BooleanInput
fullWidth
source="keep_profiles"
label="delete_media.fields.keep_profiles"
defaultValue={true}
@@ -86,20 +83,18 @@ export const DeleteMediaButton = (props: ButtonProps) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const dataProvider = useDataProvider<SynapseDataProvider>();
const { mutate: deleteMedia, isLoading } = useMutation(
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
{
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
}
);
const { mutate: deleteMedia, isPending } = useMutation({
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
});
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
@@ -110,7 +105,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
{...props}
label="delete_media.action.send"
onClick={openDialog}
disabled={isLoading}
disabled={isPending}
sx={{
color: theme.palette.error.main,
"&:hover": {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = {
...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: {
auth: {
base_url: "Heimserver URL",
@@ -125,6 +133,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",
@@ -142,9 +151,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: {
@@ -197,6 +208,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.",
},
},
},

View File

@@ -124,6 +124,7 @@ const en: SynapseTranslationMessages = {
erased: "Erased",
guests: "Show guests",
show_deactivated: "Show deactivated users",
show_locked: "Show locked users",
user_id: "Search user",
displayname: "Displayname",
password: "Password",
@@ -141,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: {
@@ -196,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.",
},
},
},

View File

@@ -120,6 +120,7 @@ const fa: SynapseTranslationMessages = {
deactivated: "غیرفعال",
guests: "نمایش مهمانان",
show_deactivated: "نمایش کاربران غیرفعال شده",
show_locked: "نمایش کاربران قفل شده",
user_id: "جستجوی کاربر",
displayname: "نام نمایشی",
password: "رمز عبور",

View File

@@ -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.",
},
},
},

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

@@ -120,6 +120,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
erased?: string; // TODO: fa, fr, it, zh
guests: string;
show_deactivated: string;
show_locked?: string; // TODO: de, fa, fr, it, zh
user_id: string;
displayname: string;
password: string;
@@ -137,9 +138,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
password?: string;
deactivate: string;
erase: string;
erase_admin_error: string;
};
action: {
erase: string;
erase_avatar: string;
};
};
rooms: {
@@ -190,6 +193,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
erase: {
title: string;
content: string;
fields: {
block: string;
},
success: string;
failure: string;
};
};
};

View File

@@ -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: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = {
...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: {
auth: {
base_url: "Адрес домашнего сервера",
@@ -133,6 +141,7 @@ const ru: SynapseTranslationMessages = {
erased: "Удалён",
guests: "Показывать гостей",
show_deactivated: "Показывать деактивированных",
show_locked: "Показывать заблокированных",
user_id: "Поиск пользователя",
displayname: "Отображаемое имя",
password: "Пароль",
@@ -150,9 +159,11 @@ const ru: SynapseTranslationMessages = {
password: "Смена пароля завершит все сессии пользователя.",
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_admin_error: "Удаление собственного пользователя запрещено.",
},
action: {
erase: "Удалить данные пользователя",
erase_avatar: "Удалить аватар",
},
},
rooms: {
@@ -208,6 +219,11 @@ const ru: SynapseTranslationMessages = {
title: "Удалить комнату",
content:
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
fields: {
block: "Заблокировать и запретить пользователям присоединяться к комнате",
},
success: "Комната/ы успешно удалены",
failure: "Комната/ы не могут быть удалены.",
},
},
},

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = {
...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: {
auth: {
base_url: "服务器 URL",
@@ -120,6 +128,7 @@ const zh: SynapseTranslationMessages = {
deactivated: "被禁用",
guests: "显示访客",
show_deactivated: "显示被禁用的账户",
show_locked: "显示被锁定的账户",
user_id: "搜索用户",
displayname: "显示名字",
password: "密码",
@@ -134,9 +143,11 @@ const zh: SynapseTranslationMessages = {
helper: {
deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_admin_error: "不允许删除自己的用户",
},
action: {
erase: "抹除用户信息",
erase_avatar: "抹掉头像",
},
},
rooms: {

View File

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

View File

@@ -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,7 +222,6 @@ const LoginPage = () => {
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -229,7 +233,6 @@ const LoginPage = () => {
autoComplete="current-password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -242,7 +245,6 @@ const LoginPage = () => {
disabled={loading}
readOnly={allowSingleBaseUrl}
resettable={allowAnyBaseUrl}
fullWidth
validate={[required(), validateBaseUrl]}
>
{allowMultipleBaseUrls &&
@@ -275,9 +277,9 @@ const LoginPage = () => {
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
fullWidth
value={locale}
onChange={e => setLocale(e.target.value)}
fullWidth
disabled={loading}
className="select"
>

View File

@@ -27,14 +27,19 @@ import {
useNotify,
useRefresh,
useTranslate,
DateFieldProps,
} from "react-admin";
import { DATE_FORMAT } from "../components/date";
import { get } from "lodash";
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const destinationRowSx = (record: RaRecord) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
backgroundColor: record.retry_last_ts > 0 ? "warning.light" : "primary.contrastText",
"& .MuiButtonBase-root": {
color: "primary.dark",
},
});
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
@@ -92,6 +97,14 @@ const DestinationTitle = () => {
);
};
const RetryDateField = (props: DateFieldProps) => {
const record = useRecordContext(props);
if (props.source && get(record, props.source) === 0) {
return <DateField {...props} record={{ ...record, [props.source]: null }} />;
}
return <DateField {...props} />;
};
export const DestinationList = (props: ListProps) => {
return (
<List
@@ -103,7 +116,7 @@ export const DestinationList = (props: ListProps) => {
<Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
<DateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
<TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton />

View File

@@ -25,7 +25,7 @@ import {
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import AvatarField from "../components/AvatarField";
@@ -70,27 +70,25 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
() =>
const { mutate, isPending } = useMutation({
mutationFn: () =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
});
return (
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isLoading}>
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
<RoomDirectoryIcon />
</Button>
);
@@ -102,6 +100,10 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
if (!record) {
return null;
}
const handleSend = () => {
create(
"room_directory",

View File

@@ -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]} />;
@@ -65,13 +67,16 @@ const RoomTitle = () => {
const RoomShowActions = () => {
const record = useRecordContext();
if (!record) {
return null;
}
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
// FIXME: refresh after (un)publish
return (
<TopToolbar>
{publishButton}
<DeleteButton
mutationMode="pessimistic"
<DeleteRoomButton
selectedIds={[record.id]}
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
/>
@@ -207,17 +212,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 />];

View File

@@ -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,11 +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";
@@ -90,29 +100,64 @@ const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
<BooleanInput label="resources.users.fields.show_deactivated" source="deactivated" alwaysOn />,
<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
{...props}
filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false }}
filterDefaultValues={{ guests: true, deactivated: false, locked: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit" 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" />
@@ -137,23 +182,35 @@ 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>
);
};
export const UserCreate = (props: CreateProps) => (
<Create {...props}>
<Create
{...props}
redirect={(resource, id, data) => {
return `users/${id}`;
}}
>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
@@ -184,25 +241,71 @@ 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>
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
<TextInput source="id" disabled />
<AvatarField source="avatar_src" 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/*": [".png", ".jpg"] }}
>
<ImageField source="src" title="Avatar" />
</ImageInput>
<TextInput source="id" readOnly />
<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" />

View File

@@ -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,7 +31,7 @@ describe("authProvider", () => {
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}',
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",
@@ -100,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();
});
});

View File

@@ -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,7 +32,10 @@ const authProvider: AuthProvider = {
}
: {
type: "m.login.password",
user: username,
identifier: {
type: "m.id.user",
user: username,
},
password: password,
}
)
@@ -41,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);
@@ -74,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();
},

View File

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

View File

@@ -1,20 +1,44 @@
import { stringify } from "query-string";
import { DataProvider, DeleteParams, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
import {
DataProvider,
DeleteParams,
HttpError,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
UpdateParams,
fetchUtils,
withLifecycleCallbacks,
} from "react-admin";
import storage from "../storage";
import { returnMXID } from "./synapse";
import { MatrixError, displayError } from "../components/error";
// Adds the access token to all requests
const jsonClient = (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) => {
@@ -28,6 +52,10 @@ const mxcUrlToHttp = (mxcUrl: string) => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const filterUndefined = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
};
interface Room {
room_id: string;
name?: string;
@@ -214,8 +242,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 = {
@@ -223,7 +262,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,
@@ -234,12 +273,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",
}),
@@ -259,7 +298,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: {
@@ -338,7 +377,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,
@@ -373,7 +412,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,
@@ -386,7 +425,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,
@@ -488,12 +527,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, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
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 from = (page - 1) * perPage;
const query = {
from: from,
@@ -504,6 +543,7 @@ const dataProvider: SynapseDataProvider = {
destination: destination,
guests: guests,
deactivated: deactivated,
locked: locked,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
@@ -514,7 +554,7 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url);
return {
@@ -568,7 +608,7 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(endpoint_url);
return {
@@ -729,6 +769,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;

View File

@@ -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
View File

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

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

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

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

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

View File

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

View File

@@ -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,

14257
yarn.lock

File diff suppressed because it is too large Load Diff