Compare commits
53 Commits
master
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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*
|
|
16
.github/CONTRIBUTING.md
vendored
Normal file
16
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Contributing to [etkecc/synapse-admin](https://github.com/etkecc/synapse-admin)
|
||||||
|
|
||||||
|
While etke.cc fork is intended to accept more QoL changes and features,
|
||||||
|
it's good idea to open PR into the upstream repo: [Awesome-Technologies/Synapse-Admin](https://github.com/Awesome-Technologies/synapse-admin).
|
||||||
|
|
||||||
|
1. Use the etkecc/synapse-admin **master** branch as your branch upstream: `git checkout master; git pull; git checkout -b my-new-feature`
|
||||||
|
2. Once your changes are ready, please, open **2** PRs: one from your branch to `Awesome-Technologies/Synapse-Admin` **master**, and another one to `etkecc/synapse-admin` **main**
|
||||||
|
3. Once PR is accepted in the `etkecc/synapse-admin`, update `README.md` file (either directly in the `main` branch, or via another PR) to add link to the merged PR in the [Fork differences](https://github.com/etkecc/synapse-admin#fork-differences) section
|
||||||
|
|
||||||
|
### Why?
|
||||||
|
|
||||||
|
The upstream project may not want to accept all the changes, so to ensure they are not lost, we will gladly add them to the etke.cc fork.
|
||||||
|
Unfortunately, it's challenging to keep changes separated, so to avoid messing upstream and fork changes (e.g., CI changes that should not be pushed to the upstream, as they intended for this fork specifically), there are 2 branches:
|
||||||
|
|
||||||
|
* `master` - read-only copy of upstream's master branch to easily sync changes, and use it as base for new PRs
|
||||||
|
* `main` - fork-own branch with all changes
|
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
|
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 ghcr.io/static-web-server/static-web-server:2
|
||||||
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=./
|
|
||||||
|
|
||||||
WORKDIR /src
|
ENV SERVER_ROOT=/app
|
||||||
|
|
||||||
# Copy .yarn directory to the working directory (must be on a separate line!)
|
COPY ./dist /app
|
||||||
# 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
|
|
||||||
|
30
README.md
30
README.md
@@ -9,6 +9,36 @@
|
|||||||
|
|
||||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||||
|
|
||||||
|
## Fork differences
|
||||||
|
|
||||||
|
### Available via CDN
|
||||||
|
|
||||||
|
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
|
||||||
|
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
|
||||||
|
user-friendly interface for managing Synapse homeservers.
|
||||||
|
|
||||||
|
The following changes are already implemented:
|
||||||
|
|
||||||
|
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
|
||||||
|
* [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)
|
||||||
|
|
||||||
|
_the list will be updated as new changes are added_
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Supported Synapse
|
### Supported Synapse
|
||||||
|
@@ -2,13 +2,13 @@ services:
|
|||||||
synapse-admin:
|
synapse-admin:
|
||||||
container_name: synapse-admin
|
container_name: synapse-admin
|
||||||
hostname: synapse-admin
|
hostname: synapse-admin
|
||||||
image: awesometechnologies/synapse-admin:latest
|
image: ghcr.io/etkecc/synapse-admin:latest
|
||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
|
|
||||||
# to use the docker-compose as standalone without a local repo clone,
|
# to use the docker-compose as standalone without a local repo clone,
|
||||||
# replace the context definition with this:
|
# replace the context definition with this:
|
||||||
# context: https://github.com/Awesome-Technologies/synapse-admin.git
|
# context: https://github.com/etkecc/synapse-admin.git
|
||||||
|
|
||||||
# args:
|
# args:
|
||||||
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
|
@@ -121,8 +121,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
<footer
|
<footer
|
||||||
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
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/Awesome-Technologies/synapse-admin"
|
<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;">
|
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
|
Synapse-Admin <b><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
|
||||||
</a>
|
</a>
|
||||||
|
15
justfile
Normal file
15
justfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Shows help
|
||||||
|
default:
|
||||||
|
@just --list --justfile {{ justfile() }}
|
||||||
|
|
||||||
|
# build the app
|
||||||
|
build: __install
|
||||||
|
@yarn run build --base=./
|
||||||
|
|
||||||
|
# run the app in a production mode
|
||||||
|
run: 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": ".",
|
"homepage": ".",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.7.0",
|
"@eslint/js": "^9.7.0",
|
||||||
"@mui/utils": "^6.1.3",
|
"@testing-library/dom": "^10.0.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"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",
|
"eslint-plugin-yaml": "^1.0.3",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
@@ -41,8 +40,8 @@
|
|||||||
"ts-jest": "^29.2.3",
|
"ts-jest": "^29.2.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"typescript-eslint": "^8.32.1",
|
"typescript-eslint": "^7.16.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^5.3.4",
|
||||||
"vite-plugin-version-mark": "^0.1.0"
|
"vite-plugin-version-mark": "^0.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -52,25 +51,25 @@
|
|||||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||||
"@mui/icons-material": "^5.16.4",
|
"@mui/icons-material": "^5.16.4",
|
||||||
"@mui/material": "^5.16.4",
|
"@mui/material": "^5.16.4",
|
||||||
"@tanstack/react-query": "^5.59.12",
|
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"query-string": "^7.1.3",
|
"query-string": "^7.1.3",
|
||||||
"ra-core": "^5.2.3",
|
"ra-core": "^4.16.20",
|
||||||
"ra-i18n-polyglot": "^5.2.3",
|
"ra-i18n-polyglot": "^4.16.20",
|
||||||
"ra-language-english": "^5.8.2",
|
"ra-language-english": "^4.16.20",
|
||||||
"ra-language-farsi": "^5.0.0",
|
"ra-language-farsi": "^4.2.0",
|
||||||
"ra-language-french": "^5.2.3",
|
"ra-language-french": "^4.16.20",
|
||||||
"ra-language-italian": "^3.13.1",
|
"ra-language-italian": "^3.13.1",
|
||||||
"ra-language-russian": "^4.14.2",
|
"ra-language-russian": "^4.14.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-admin": "^5.2.3",
|
"react-admin": "^4.16.20",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.52.1",
|
"react-hook-form": "^7.52.1",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-router": "^6.28.1",
|
"react-query": "^3.39.3",
|
||||||
"react-router-dom": "^6.28.1"
|
"react-router": "^6.25.1",
|
||||||
|
"react-router-dom": "^6.25.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite serve",
|
"start": "vite serve",
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import fetchMock from "jest-fetch-mock";
|
|
||||||
fetchMock.enableMocks();
|
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
|
@@ -53,6 +53,7 @@ const App = () => (
|
|||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
|
darkTheme={{ palette: { mode: "dark" } }}
|
||||||
>
|
>
|
||||||
<CustomRoutes>
|
<CustomRoutes>
|
||||||
<Route path="/import_users" element={<ImportFeature />} />
|
<Route path="/import_users" element={<ImportFeature />} />
|
||||||
|
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;
|
@@ -121,7 +121,7 @@ const FilePicker = () => {
|
|||||||
|
|
||||||
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => {
|
||||||
/* First, verify the presence of required fields */
|
/* First, verify the presence of required fields */
|
||||||
const missingFields = expectedFields.filter(eF => !meta.fields?.includes(eF));
|
const missingFields = expectedFields.filter(eF => meta.fields?.find(mF => eF === mF));
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
setError(translate("import_users.error.required_field", { field: missingFields[0] }));
|
||||||
|
@@ -20,7 +20,7 @@ import {
|
|||||||
useTranslate,
|
useTranslate,
|
||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -43,6 +43,7 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
source="body"
|
source="body"
|
||||||
label="resources.servernotices.fields.body"
|
label="resources.servernotices.fields.body"
|
||||||
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
rows="4"
|
rows="4"
|
||||||
resettable
|
resettable
|
||||||
@@ -63,10 +64,6 @@ export const ServerNoticeButton = () => {
|
|||||||
const handleDialogOpen = () => setOpen(true);
|
const handleDialogOpen = () => setOpen(true);
|
||||||
const handleDialogClose = () => setOpen(false);
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSend = (values: Partial<RaRecord>) => {
|
const handleSend = (values: Partial<RaRecord>) => {
|
||||||
create(
|
create(
|
||||||
"servernotices",
|
"servernotices",
|
||||||
@@ -103,26 +100,28 @@ export const ServerNoticeBulkButton = () => {
|
|||||||
const unselectAllUsers = useUnselectAll("users");
|
const unselectAllUsers = useUnselectAll("users");
|
||||||
const dataProvider = useDataProvider();
|
const dataProvider = useDataProvider();
|
||||||
|
|
||||||
const { mutate: sendNotices, isPending } = useMutation({
|
const { mutate: sendNotices, isLoading } = useMutation(
|
||||||
mutationFn: (data) =>
|
data =>
|
||||||
dataProvider.createMany("servernotices", {
|
dataProvider.createMany("servernotices", {
|
||||||
ids: selectedIds,
|
ids: selectedIds,
|
||||||
data: data,
|
data: data,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
{
|
||||||
notify("resources.servernotices.action.send_success");
|
onSuccess: () => {
|
||||||
unselectAllUsers();
|
notify("resources.servernotices.action.send_success");
|
||||||
closeDialog();
|
unselectAllUsers();
|
||||||
},
|
closeDialog();
|
||||||
onError: () =>
|
},
|
||||||
notify("resources.servernotices.action.send_failure", {
|
onError: () =>
|
||||||
type: "error",
|
notify("resources.servernotices.action.send_failure", {
|
||||||
}),
|
type: "error",
|
||||||
});
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
|
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
|
||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />
|
<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,
|
useRefresh,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "react-query";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { dateParser } from "./date";
|
import { dateParser } from "./date";
|
||||||
@@ -55,12 +55,14 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
|
||||||
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
|
fullWidth
|
||||||
source="before_ts"
|
source="before_ts"
|
||||||
label="delete_media.fields.before_ts"
|
label="delete_media.fields.before_ts"
|
||||||
defaultValue={0}
|
defaultValue={0}
|
||||||
parse={dateParser}
|
parse={dateParser}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
fullWidth
|
||||||
source="size_gt"
|
source="size_gt"
|
||||||
label="delete_media.fields.size_gt"
|
label="delete_media.fields.size_gt"
|
||||||
defaultValue={0}
|
defaultValue={0}
|
||||||
@@ -68,6 +70,7 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
|||||||
step={1024}
|
step={1024}
|
||||||
/>
|
/>
|
||||||
<BooleanInput
|
<BooleanInput
|
||||||
|
fullWidth
|
||||||
source="keep_profiles"
|
source="keep_profiles"
|
||||||
label="delete_media.fields.keep_profiles"
|
label="delete_media.fields.keep_profiles"
|
||||||
defaultValue={true}
|
defaultValue={true}
|
||||||
@@ -83,18 +86,20 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const dataProvider = useDataProvider<SynapseDataProvider>();
|
const dataProvider = useDataProvider<SynapseDataProvider>();
|
||||||
const { mutate: deleteMedia, isPending } = useMutation({
|
const { mutate: deleteMedia, isLoading } = useMutation(
|
||||||
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
|
||||||
onSuccess: () => {
|
{
|
||||||
notify("delete_media.action.send_success");
|
onSuccess: () => {
|
||||||
closeDialog();
|
notify("delete_media.action.send_success");
|
||||||
},
|
closeDialog();
|
||||||
onError: () => {
|
},
|
||||||
notify("delete_media.action.send_failure", {
|
onError: () => {
|
||||||
type: "error",
|
notify("delete_media.action.send_failure", {
|
||||||
});
|
type: "error",
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const openDialog = () => setOpen(true);
|
const openDialog = () => setOpen(true);
|
||||||
const closeDialog = () => setOpen(false);
|
const closeDialog = () => setOpen(false);
|
||||||
@@ -105,7 +110,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
label="delete_media.action.send"
|
label="delete_media.action.send"
|
||||||
onClick={openDialog}
|
onClick={openDialog}
|
||||||
disabled={isPending}
|
disabled={isLoading}
|
||||||
sx={{
|
sx={{
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const de: SynapseTranslationMessages = {
|
const de: SynapseTranslationMessages = {
|
||||||
...formalGermanMessages,
|
...formalGermanMessages,
|
||||||
ra: {
|
|
||||||
...formalGermanMessages.ra,
|
|
||||||
navigation: {
|
|
||||||
...formalGermanMessages.ra.navigation,
|
|
||||||
no_filtered_results: "Keine Ergebnisse",
|
|
||||||
clear_filters: "Alle Filter entfernen",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Heimserver URL",
|
base_url: "Heimserver URL",
|
||||||
@@ -133,6 +125,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
erased: "Gelöscht",
|
erased: "Gelöscht",
|
||||||
guests: "Zeige Gäste",
|
guests: "Zeige Gäste",
|
||||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||||
|
show_locked: "Zeige gesperrte Benutzer",
|
||||||
user_id: "Suche Benutzer",
|
user_id: "Suche Benutzer",
|
||||||
displayname: "Anzeigename",
|
displayname: "Anzeigename",
|
||||||
password: "Passwort",
|
password: "Passwort",
|
||||||
@@ -150,6 +143,7 @@ const de: SynapseTranslationMessages = {
|
|||||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||||
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
||||||
|
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Lösche Benutzerdaten",
|
erase: "Lösche Benutzerdaten",
|
||||||
@@ -205,6 +199,11 @@ const de: SynapseTranslationMessages = {
|
|||||||
title: "Raum löschen",
|
title: "Raum löschen",
|
||||||
content:
|
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!",
|
"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,6 +142,7 @@ const en: SynapseTranslationMessages = {
|
|||||||
password: "Changing password will log user out of all sessions.",
|
password: "Changing password will log user out of all sessions.",
|
||||||
deactivate: "You must provide a password to re-activate an account.",
|
deactivate: "You must provide a password to re-activate an account.",
|
||||||
erase: "Mark the user as GDPR-erased",
|
erase: "Mark the user as GDPR-erased",
|
||||||
|
erase_admin_error: "Deleting own user is not allowed.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Erase user data",
|
erase: "Erase user data",
|
||||||
@@ -197,6 +198,11 @@ const en: SynapseTranslationMessages = {
|
|||||||
title: "Delete room",
|
title: "Delete room",
|
||||||
content:
|
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!",
|
"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: "غیرفعال",
|
deactivated: "غیرفعال",
|
||||||
guests: "نمایش مهمانان",
|
guests: "نمایش مهمانان",
|
||||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||||
|
show_locked: "نمایش کاربران قفل شده",
|
||||||
user_id: "جستجوی کاربر",
|
user_id: "جستجوی کاربر",
|
||||||
displayname: "نام نمایشی",
|
displayname: "نام نمایشی",
|
||||||
password: "رمز عبور",
|
password: "رمز عبور",
|
||||||
|
@@ -124,6 +124,7 @@ const fr: SynapseTranslationMessages = {
|
|||||||
deactivated: "Désactivé",
|
deactivated: "Désactivé",
|
||||||
guests: "Afficher les visiteurs",
|
guests: "Afficher les visiteurs",
|
||||||
show_deactivated: "Afficher les utilisateurs désactivés",
|
show_deactivated: "Afficher les utilisateurs désactivés",
|
||||||
|
show_locked: "Afficher les utilisateurs verrouillés",
|
||||||
user_id: "Rechercher un utilisateur",
|
user_id: "Rechercher un utilisateur",
|
||||||
displayname: "Nom d'affichage",
|
displayname: "Nom d'affichage",
|
||||||
password: "Mot de passe",
|
password: "Mot de passe",
|
||||||
@@ -139,6 +140,7 @@ const fr: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||||
|
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Effacer les données de l'utilisateur",
|
erase: "Effacer les données de l'utilisateur",
|
||||||
@@ -194,6 +196,11 @@ const fr: SynapseTranslationMessages = {
|
|||||||
title: "Supprimer le salon",
|
title: "Supprimer le salon",
|
||||||
content:
|
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 !",
|
"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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
6
src/i18n/index.d.ts
vendored
6
src/i18n/index.d.ts
vendored
@@ -138,6 +138,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deactivate: string;
|
deactivate: string;
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_admin_error: string;
|
||||||
};
|
};
|
||||||
action: {
|
action: {
|
||||||
erase: string;
|
erase: string;
|
||||||
@@ -191,6 +192,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
erase: {
|
erase: {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
fields: {
|
||||||
|
block: string;
|
||||||
|
},
|
||||||
|
success: string;
|
||||||
|
failure: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -121,6 +121,7 @@ const it: SynapseTranslationMessages = {
|
|||||||
deactivated: "Disattivato",
|
deactivated: "Disattivato",
|
||||||
guests: "Mostra gli ospiti",
|
guests: "Mostra gli ospiti",
|
||||||
show_deactivated: "Mostra gli utenti disattivati",
|
show_deactivated: "Mostra gli utenti disattivati",
|
||||||
|
show_locked: "Mostra gli utenti bloccati",
|
||||||
user_id: "Cerca utente",
|
user_id: "Cerca utente",
|
||||||
displayname: "Nickname",
|
displayname: "Nickname",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
@@ -141,6 +142,7 @@ const it: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Cancella i dati dell'utente",
|
erase: "Cancella i dati dell'utente",
|
||||||
|
erase_admin_error: "Non è consentito eliminare il proprio utente.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const ru: SynapseTranslationMessages = {
|
const ru: SynapseTranslationMessages = {
|
||||||
...russianMessages,
|
...russianMessages,
|
||||||
ra: {
|
|
||||||
...russianMessages.ra,
|
|
||||||
navigation: {
|
|
||||||
...russianMessages.ra.navigation,
|
|
||||||
no_filtered_results: "Нет результатов",
|
|
||||||
clear_filters: "Все фильтры сбросить",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Адрес домашнего сервера",
|
base_url: "Адрес домашнего сервера",
|
||||||
@@ -141,6 +133,7 @@ const ru: SynapseTranslationMessages = {
|
|||||||
erased: "Удалён",
|
erased: "Удалён",
|
||||||
guests: "Показывать гостей",
|
guests: "Показывать гостей",
|
||||||
show_deactivated: "Показывать деактивированных",
|
show_deactivated: "Показывать деактивированных",
|
||||||
|
show_locked: "Показывать заблокированных",
|
||||||
user_id: "Поиск пользователя",
|
user_id: "Поиск пользователя",
|
||||||
displayname: "Отображаемое имя",
|
displayname: "Отображаемое имя",
|
||||||
password: "Пароль",
|
password: "Пароль",
|
||||||
@@ -158,6 +151,7 @@ const ru: SynapseTranslationMessages = {
|
|||||||
password: "Смена пароля завершит все сессии пользователя.",
|
password: "Смена пароля завершит все сессии пользователя.",
|
||||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||||
|
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Удалить данные пользователя",
|
erase: "Удалить данные пользователя",
|
||||||
@@ -216,6 +210,11 @@ const ru: SynapseTranslationMessages = {
|
|||||||
title: "Удалить комнату",
|
title: "Удалить комнату",
|
||||||
content:
|
content:
|
||||||
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
||||||
|
fields: {
|
||||||
|
block: "Заблокировать и запретить пользователям присоединяться к комнате",
|
||||||
|
},
|
||||||
|
success: "Комната/ы успешно удалены",
|
||||||
|
failure: "Комната/ы не могут быть удалены.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -4,14 +4,6 @@ import { SynapseTranslationMessages } from ".";
|
|||||||
|
|
||||||
const zh: SynapseTranslationMessages = {
|
const zh: SynapseTranslationMessages = {
|
||||||
...chineseMessages,
|
...chineseMessages,
|
||||||
ra: {
|
|
||||||
...chineseMessages.ra,
|
|
||||||
navigation: {
|
|
||||||
...chineseMessages.ra.navigation,
|
|
||||||
no_filtered_results: "没有结果",
|
|
||||||
clear_filters: "清除所有过滤器",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "服务器 URL",
|
base_url: "服务器 URL",
|
||||||
@@ -128,6 +120,7 @@ const zh: SynapseTranslationMessages = {
|
|||||||
deactivated: "被禁用",
|
deactivated: "被禁用",
|
||||||
guests: "显示访客",
|
guests: "显示访客",
|
||||||
show_deactivated: "显示被禁用的账户",
|
show_deactivated: "显示被禁用的账户",
|
||||||
|
show_locked: "显示被锁定的账户",
|
||||||
user_id: "搜索用户",
|
user_id: "搜索用户",
|
||||||
displayname: "显示名字",
|
displayname: "显示名字",
|
||||||
password: "密码",
|
password: "密码",
|
||||||
@@ -142,6 +135,7 @@ const zh: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "您必须提供一串密码来激活账户。",
|
deactivate: "您必须提供一串密码来激活账户。",
|
||||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||||
|
erase_admin_error: "不允许删除自己的用户",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "抹除用户信息",
|
erase: "抹除用户信息",
|
||||||
|
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AppContext } from "./AppContext";
|
import { AppContext } from "./AppContext";
|
||||||
|
|
||||||
fetch(`${import.meta.env.BASE_URL}/config.json`)
|
fetch("config.json")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(props =>
|
.then(props =>
|
||||||
createRoot(document.getElementById("root")).render(
|
createRoot(document.getElementById("root")).render(
|
||||||
|
@@ -168,7 +168,9 @@ const LoginPage = () => {
|
|||||||
const [matrixVersions, setMatrixVersions] = useState("");
|
const [matrixVersions, setMatrixVersions] = useState("");
|
||||||
|
|
||||||
const handleUsernameChange = () => {
|
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
|
// check if username is a full qualified userId then set base_url accordingly
|
||||||
const domain = splitMxid(formData.username)?.domain;
|
const domain = splitMxid(formData.username)?.domain;
|
||||||
if (domain) {
|
if (domain) {
|
||||||
@@ -180,6 +182,9 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!formData.base_url) {
|
||||||
|
form.setValue("base_url", "");
|
||||||
|
}
|
||||||
if (formData.base_url === "" && allowMultipleBaseUrls) {
|
if (formData.base_url === "" && allowMultipleBaseUrls) {
|
||||||
form.setValue("base_url", restrictBaseUrl[0]);
|
form.setValue("base_url", restrictBaseUrl[0]);
|
||||||
}
|
}
|
||||||
@@ -217,6 +222,7 @@ const LoginPage = () => {
|
|||||||
disabled={loading || !supportPassAuth}
|
disabled={loading || !supportPassAuth}
|
||||||
onBlur={handleUsernameChange}
|
onBlur={handleUsernameChange}
|
||||||
resettable
|
resettable
|
||||||
|
fullWidth
|
||||||
validate={required()}
|
validate={required()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -228,6 +234,7 @@ const LoginPage = () => {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={loading || !supportPassAuth}
|
disabled={loading || !supportPassAuth}
|
||||||
resettable
|
resettable
|
||||||
|
fullWidth
|
||||||
validate={required()}
|
validate={required()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -240,6 +247,7 @@ const LoginPage = () => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
readOnly={allowSingleBaseUrl}
|
readOnly={allowSingleBaseUrl}
|
||||||
resettable={allowAnyBaseUrl}
|
resettable={allowAnyBaseUrl}
|
||||||
|
fullWidth
|
||||||
validate={[required(), validateBaseUrl]}
|
validate={[required(), validateBaseUrl]}
|
||||||
>
|
>
|
||||||
{allowMultipleBaseUrls &&
|
{allowMultipleBaseUrls &&
|
||||||
|
@@ -1,11 +1,9 @@
|
|||||||
import { get } from "lodash";
|
|
||||||
import { MouseEvent } from "react";
|
import { MouseEvent } from "react";
|
||||||
|
|
||||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||||
import DestinationsIcon from "@mui/icons-material/CloudQueue";
|
import DestinationsIcon from "@mui/icons-material/CloudQueue";
|
||||||
import FolderSharedIcon from "@mui/icons-material/FolderShared";
|
import FolderSharedIcon from "@mui/icons-material/FolderShared";
|
||||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||||
import { blue } from "@mui/material/colors";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
@@ -33,10 +31,17 @@ import {
|
|||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import { DATE_FORMAT } from "../components/date";
|
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 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 />];
|
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
|
||||||
|
|
||||||
export const DestinationReconnectButton = () => {
|
export const DestinationReconnectButton = () => {
|
||||||
@@ -101,16 +106,6 @@ const RetryDateField = (props: DateFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DestinationList = (props: ListProps) => {
|
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 (
|
return (
|
||||||
<List
|
<List
|
||||||
{...props}
|
{...props}
|
||||||
|
@@ -25,7 +25,7 @@ import {
|
|||||||
useRefresh,
|
useRefresh,
|
||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
import AvatarField from "../components/AvatarField";
|
import AvatarField from "../components/AvatarField";
|
||||||
|
|
||||||
@@ -70,25 +70,27 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
|
|||||||
const refresh = useRefresh();
|
const refresh = useRefresh();
|
||||||
const unselectAllRooms = useUnselectAll("rooms");
|
const unselectAllRooms = useUnselectAll("rooms");
|
||||||
const dataProvider = useDataProvider();
|
const dataProvider = useDataProvider();
|
||||||
const { mutate, isPending } = useMutation({
|
const { mutate, isLoading } = useMutation(
|
||||||
mutationFn: () =>
|
() =>
|
||||||
dataProvider.createMany("room_directory", {
|
dataProvider.createMany("room_directory", {
|
||||||
ids: selectedIds,
|
ids: selectedIds,
|
||||||
data: {},
|
data: {},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
{
|
||||||
notify("resources.room_directory.action.send_success");
|
onSuccess: () => {
|
||||||
unselectAllRooms();
|
notify("resources.room_directory.action.send_success");
|
||||||
refresh();
|
unselectAllRooms();
|
||||||
},
|
refresh();
|
||||||
onError: () =>
|
},
|
||||||
notify("resources.room_directory.action.send_failure", {
|
onError: () =>
|
||||||
type: "error",
|
notify("resources.room_directory.action.send_failure", {
|
||||||
}),
|
type: "error",
|
||||||
});
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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 />
|
<RoomDirectoryIcon />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -100,10 +102,6 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
|
|||||||
const refresh = useRefresh();
|
const refresh = useRefresh();
|
||||||
const [create, { isLoading }] = useCreate();
|
const [create, { isLoading }] = useCreate();
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
create(
|
create(
|
||||||
"room_directory",
|
"room_directory",
|
||||||
|
@@ -36,6 +36,7 @@ import {
|
|||||||
TopToolbar,
|
TopToolbar,
|
||||||
useRecordContext,
|
useRecordContext,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
|
useListContext,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
RoomDirectoryPublishButton,
|
RoomDirectoryPublishButton,
|
||||||
} from "./room_directory";
|
} from "./room_directory";
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
|
import DeleteRoomButton from "../components/DeleteRoomButton";
|
||||||
|
|
||||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
@@ -70,8 +72,8 @@ const RoomShowActions = () => {
|
|||||||
return (
|
return (
|
||||||
<TopToolbar>
|
<TopToolbar>
|
||||||
{publishButton}
|
{publishButton}
|
||||||
<DeleteButton
|
<DeleteRoomButton
|
||||||
mutationMode="pessimistic"
|
selectedIds={[record.id]}
|
||||||
confirmTitle="resources.rooms.action.erase.title"
|
confirmTitle="resources.rooms.action.erase.title"
|
||||||
confirmContent="resources.rooms.action.erase.content"
|
confirmContent="resources.rooms.action.erase.content"
|
||||||
/>
|
/>
|
||||||
@@ -207,17 +209,20 @@ export const RoomShow = (props: ShowProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoomBulkActionButtons = () => (
|
const RoomBulkActionButtons = () => {
|
||||||
<>
|
const record = useListContext();
|
||||||
<RoomDirectoryBulkPublishButton />
|
return (
|
||||||
<RoomDirectoryBulkUnpublishButton />
|
<>
|
||||||
<BulkDeleteButton
|
<RoomDirectoryBulkPublishButton />
|
||||||
confirmTitle="resources.rooms.action.erase.title"
|
<RoomDirectoryBulkUnpublishButton />
|
||||||
confirmContent="resources.rooms.action.erase.content"
|
<DeleteRoomButton
|
||||||
mutationMode="pessimistic"
|
selectedIds={record.selectedIds}
|
||||||
/>
|
confirmTitle="resources.rooms.action.erase.title"
|
||||||
</>
|
confirmContent="resources.rooms.action.erase.content"
|
||||||
);
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
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 PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, ownerDocument } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
ArrayInput,
|
ArrayInput,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
@@ -42,12 +44,17 @@ import {
|
|||||||
useRecordContext,
|
useRecordContext,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
SaveButton,
|
||||||
CreateButton,
|
CreateButton,
|
||||||
ExportButton,
|
ExportButton,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
|
Toolbar,
|
||||||
NumberField,
|
NumberField,
|
||||||
useListContext,
|
useListContext,
|
||||||
|
useNotify,
|
||||||
|
ToolbarClasses,
|
||||||
Identifier,
|
Identifier,
|
||||||
|
RaRecord,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
@@ -94,16 +101,51 @@ const userFilters = [
|
|||||||
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
|
||||||
];
|
];
|
||||||
|
|
||||||
const UserBulkActionButtons = () => (
|
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 />
|
<ServerNoticeBulkButton />
|
||||||
<BulkDeleteButton
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||||
label="resources.users.action.erase"
|
<BulkDeleteButton
|
||||||
confirmTitle="resources.users.helper.erase"
|
label="resources.users.action.erase"
|
||||||
mutationMode="pessimistic"
|
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) => (
|
export const UserList = (props: ListProps) => (
|
||||||
<List
|
<List
|
||||||
@@ -114,10 +156,7 @@ export const UserList = (props: ListProps) => (
|
|||||||
actions={<UserListActions />}
|
actions={<UserListActions />}
|
||||||
pagination={<UserPagination />}
|
pagination={<UserPagination />}
|
||||||
>
|
>
|
||||||
<Datagrid
|
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
|
||||||
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
|
|
||||||
bulkActionButtons={<UserBulkActionButtons />}
|
|
||||||
>
|
|
||||||
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
|
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
|
||||||
<TextField source="id" sortBy="name" />
|
<TextField source="id" sortBy="name" />
|
||||||
<TextField source="displayname" />
|
<TextField source="displayname" />
|
||||||
@@ -142,28 +181,32 @@ const validateAddress = [required(), maxLength(255)];
|
|||||||
const UserEditActions = () => {
|
const UserEditActions = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
|
let ownUserIsSelected = false;
|
||||||
|
if (record && record.id) {
|
||||||
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopToolbar>
|
<TopToolbar>
|
||||||
{!record?.deactivated && <ServerNoticeButton />}
|
{!record?.deactivated && <ServerNoticeButton />}
|
||||||
<DeleteButton
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
|
||||||
label="resources.users.action.erase"
|
<DeleteButton
|
||||||
confirmTitle={translate("resources.users.helper.erase", {
|
label="resources.users.action.erase"
|
||||||
smart_count: 1,
|
confirmTitle={translate("resources.users.helper.erase", {
|
||||||
})}
|
smart_count: 1,
|
||||||
mutationMode="pessimistic"
|
})}
|
||||||
/>
|
mutationMode="pessimistic"
|
||||||
|
/>
|
||||||
|
</UserPreventSelfDelete>
|
||||||
</TopToolbar>
|
</TopToolbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserCreate = (props: CreateProps) => (
|
export const UserCreate = (props: CreateProps) => (
|
||||||
<Create
|
<Create { ...props} redirect={(resource, id, data) => {
|
||||||
{...props}
|
return `users/${id}`;
|
||||||
redirect={(resource: string | undefined, id: Identifier | undefined) => {
|
}}>
|
||||||
return `${resource}/${id}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SimpleForm>
|
<SimpleForm>
|
||||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||||
<TextInput source="displayname" validate={maxLength(256)} />
|
<TextInput source="displayname" validate={maxLength(256)} />
|
||||||
@@ -194,16 +237,49 @@ const UserTitle = () => {
|
|||||||
{translate("resources.users.name", {
|
{translate("resources.users.name", {
|
||||||
smart_count: 1,
|
smart_count: 1,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
{record ? `"${record.displayname}"` : ""}
|
{record ? ( record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
|
||||||
</span>
|
</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");
|
||||||
|
const isOwnUser = false;
|
||||||
|
let ownUserIsSelected = false;
|
||||||
|
if (record && (record.id === ownUserId)) {
|
||||||
|
ownUserIsSelected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}><BooleanInput {...props} disabled={ownUserIsSelected} /></UserPreventSelfDelete>
|
||||||
|
}
|
||||||
|
|
||||||
export const UserEdit = (props: EditProps) => {
|
export const UserEdit = (props: EditProps) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
||||||
<TabbedForm>
|
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||||
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
|
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
|
||||||
<TextInput source="id" disabled />
|
<TextInput source="id" disabled />
|
||||||
@@ -212,7 +288,7 @@ export const UserEdit = (props: EditProps) => {
|
|||||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||||
<BooleanInput source="admin" />
|
<BooleanInput source="admin" />
|
||||||
<BooleanInput source="locked" />
|
<BooleanInput source="locked" />
|
||||||
<BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
|
||||||
<BooleanInput source="erased" disabled />
|
<BooleanInput source="erased" disabled />
|
||||||
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
|
||||||
<TextField source="consent_version" />
|
<TextField source="consent_version" />
|
||||||
|
@@ -2,6 +2,7 @@ import fetchMock from "jest-fetch-mock";
|
|||||||
|
|
||||||
import authProvider from "./authProvider";
|
import authProvider from "./authProvider";
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { HttpError } from "ra-core";
|
||||||
|
|
||||||
fetchMock.enableMocks();
|
fetchMock.enableMocks();
|
||||||
|
|
||||||
@@ -30,17 +31,7 @@ describe("authProvider", () => {
|
|||||||
|
|
||||||
expect(ret).toBe(undefined);
|
expect(ret).toBe(undefined);
|
||||||
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
|
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
|
||||||
body: JSON.stringify({
|
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"}',
|
||||||
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",
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -114,7 +105,7 @@ describe("authProvider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reject if error.status is 403", async () => {
|
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 storage from "../storage";
|
||||||
|
import { MatrixError, displayError } from "../components/error";
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
const authProvider: AuthProvider = {
|
||||||
// called when the user attempts to log in
|
// called when the user attempts to log in
|
||||||
@@ -31,12 +32,11 @@ const authProvider: AuthProvider = {
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: "m.login.password",
|
type: "m.login.password",
|
||||||
user: username,
|
|
||||||
password: password,
|
|
||||||
identifier: {
|
identifier: {
|
||||||
type: "m.id.user",
|
type: "m.id.user",
|
||||||
user: username,
|
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
|
// 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
|
// server, since the admin might want to access the admin API via some
|
||||||
// private address
|
// 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, "");
|
base_url = base_url.replace(/\/+$/g, "");
|
||||||
storage.setItem("base_url", base_url);
|
storage.setItem("base_url", base_url);
|
||||||
|
|
||||||
const decoded_base_url = window.decodeURIComponent(base_url);
|
const decoded_base_url = window.decodeURIComponent(base_url);
|
||||||
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
|
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("home_server", json.home_server);
|
||||||
storage.setItem("user_id", json.user_id);
|
storage.setItem("user_id", json.user_id);
|
||||||
storage.setItem("access_token", json.access_token);
|
storage.setItem("access_token", json.access_token);
|
||||||
@@ -78,10 +101,12 @@ const authProvider: AuthProvider = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// called when the API returns an error
|
// called when the API returns an error
|
||||||
checkError: ({ status }: { status: number }) => {
|
checkError: (err: HttpError) => {
|
||||||
console.log("checkError " + status);
|
const errorBody = err.body as MatrixError;
|
||||||
|
const status = err.status;
|
||||||
|
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
return Promise.reject();
|
return Promise.reject({message: displayError(errorBody.errcode, status, errorBody.error)});
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
@@ -1,18 +1,12 @@
|
|||||||
import { stringify } from "query-string";
|
import { stringify } from "query-string";
|
||||||
import {
|
|
||||||
DataProvider,
|
import { DataProvider, DeleteParams, HttpError, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
|
||||||
DeleteParams,
|
|
||||||
Identifier,
|
|
||||||
Options,
|
|
||||||
PaginationPayload,
|
|
||||||
RaRecord,
|
|
||||||
SortPayload,
|
|
||||||
fetchUtils
|
|
||||||
} from "react-admin";
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { MatrixError, displayError } from "../components/error";
|
||||||
|
|
||||||
// Adds the access token to all requests
|
// 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");
|
const token = storage.getItem("access_token");
|
||||||
console.log("httpClient " + url);
|
console.log("httpClient " + url);
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
@@ -21,7 +15,17 @@ const jsonClient = (url: string, options: Options = {}) => {
|
|||||||
token: `Bearer ${token}`,
|
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) => {
|
const mxcUrlToHttp = (mxcUrl: string) => {
|
||||||
@@ -266,7 +270,7 @@ const resourceMap = {
|
|||||||
total: json => json.total_rooms,
|
total: json => json.total_rooms,
|
||||||
delete: (params: DeleteParams) => ({
|
delete: (params: DeleteParams) => ({
|
||||||
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
|
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
|
||||||
body: { block: false },
|
body: { block: params.meta?.block ?? false },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
@@ -499,8 +503,8 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
getList: async (resource, params) => {
|
getList: async (resource, params) => {
|
||||||
console.log("getList " + resource);
|
console.log("getList " + resource);
|
||||||
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
||||||
const { page, perPage } = params.pagination as PaginationPayload;
|
const { page, perPage } = params.pagination;
|
||||||
const { field, order } = params.sort as SortPayload;
|
const { field, order } = params.sort;
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
const query = {
|
const query = {
|
||||||
from: from,
|
from: from,
|
||||||
|
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
vitePluginVersionMark({
|
vitePluginVersionMark({
|
||||||
command: "git describe --tags",
|
command: "git describe --tags || git rev-parse --short HEAD",
|
||||||
ifMeta: true,
|
ifMeta: true,
|
||||||
ifLog: true,
|
ifLog: true,
|
||||||
ifGlobal: true,
|
ifGlobal: true,
|
||||||
|
Reference in New Issue
Block a user