Compare commits
108 Commits
master
...
v0.10.3-et
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4f2cd38344 | ||
![]() |
ca71038874 | ||
![]() |
be867b6b0d | ||
![]() |
f2f540b429 | ||
![]() |
7feec4ba07 | ||
![]() |
1d5fef1e53 | ||
![]() |
9c40efde17 | ||
![]() |
53dff66978 | ||
![]() |
3a595247e8 | ||
![]() |
33f5f60e31 | ||
![]() |
9dd2ea57c9 | ||
![]() |
fae7a696de | ||
![]() |
49e8b2d0f5 | ||
![]() |
281d908d3f | ||
![]() |
bacc42fe9c | ||
![]() |
1c26a28ca9 | ||
![]() |
d3a04cd132 | ||
![]() |
e6060a23ac | ||
![]() |
4b7fbf483a | ||
![]() |
bc3c30da92 | ||
![]() |
1896f770d1 | ||
![]() |
99d0b9ad72 | ||
![]() |
944afb9056 | ||
![]() |
23f5a24803 | ||
![]() |
60ae00ac14 | ||
![]() |
26862fa708 | ||
![]() |
853d14c1ce | ||
![]() |
11a5cac709 | ||
![]() |
0d021021df | ||
![]() |
19302466ef | ||
![]() |
0594259ae4 | ||
![]() |
ba485bbb18 | ||
![]() |
f5d6f24b30 | ||
![]() |
a42efe7eda | ||
![]() |
31d3712dbb | ||
![]() |
a15dad4a31 | ||
![]() |
9062a6afb1 | ||
![]() |
a79c3597d6 | ||
![]() |
470f1b5455 | ||
![]() |
46cc0e2fda | ||
![]() |
f3080e9468 | ||
![]() |
f8fe1166e2 | ||
![]() |
02bee92806 | ||
![]() |
b7f8e03894 | ||
![]() |
59bb4b4183 | ||
![]() |
bb53d53692 | ||
![]() |
52a2f1c936 | ||
![]() |
e328380c77 | ||
![]() |
a277ded227 | ||
![]() |
48d933e028 | ||
![]() |
24cf0a60bf | ||
![]() |
d5113aad72 | ||
![]() |
6957cb1f7d | ||
![]() |
66c706532a | ||
![]() |
332e98a095 | ||
![]() |
f639a1c9ff | ||
![]() |
8282bb0926 | ||
![]() |
264d2ff59d | ||
![]() |
90a6c7d8c2 | ||
![]() |
7de9166648 | ||
![]() |
0bf3440fc8 | ||
![]() |
cceae77529 | ||
![]() |
1474b46ff0 | ||
![]() |
01e3947b22 | ||
![]() |
e093bd8625 | ||
![]() |
390aab5ce7 | ||
![]() |
fb1a04971b | ||
![]() |
eff17a0929 | ||
![]() |
8eaaaa50ec | ||
![]() |
85b305d117 | ||
![]() |
4cbea6ffb6 | ||
![]() |
1967546ae4 | ||
![]() |
c9a3294852 | ||
![]() |
f1839387e2 | ||
![]() |
c8246a1d19 | ||
![]() |
3c6259cc88 | ||
![]() |
317df5af0f | ||
![]() |
2142770a5b | ||
![]() |
51297b49fc | ||
![]() |
311cc2a1f4 | ||
![]() |
6bc760a6fa | ||
![]() |
50c96cfd77 | ||
![]() |
7747dc7f28 | ||
![]() |
678867e981 | ||
![]() |
4a4fae104e | ||
![]() |
265b5157af | ||
![]() |
b5b593945d | ||
![]() |
15c8a30c92 | ||
![]() |
40c6eb0b95 | ||
![]() |
4c4e3a07f6 | ||
![]() |
35322fa01d | ||
![]() |
88e88fb6b9 | ||
![]() |
7016e5a349 | ||
![]() |
c7e275d4ec | ||
![]() |
b1d3340fce | ||
![]() |
2ae5930578 | ||
![]() |
9dd6940383 | ||
![]() |
e2194f5c49 | ||
![]() |
e6dcbcb052 | ||
![]() |
5acf3042c3 | ||
![]() |
7286abaaae | ||
![]() |
0d5e95fa7c | ||
![]() |
c4b54c40fb | ||
![]() |
ec0b980e06 | ||
![]() |
94ccd3ad36 | ||
![]() |
49d67f9130 | ||
![]() |
2bb846734e | ||
![]() |
056d9c6b4c |
@@ -1,13 +0,0 @@
|
|||||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
|
||||||
tests/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
lib/
|
|
||||||
node_modules/
|
|
||||||
electron_app/
|
|
||||||
karma-reports/
|
|
||||||
.pnp.cjs
|
|
||||||
.pnp.loader.mjs
|
|
||||||
.idea/
|
|
||||||
.tmp/
|
|
||||||
config.json*
|
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
liberapay: etkecc
|
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -4,10 +4,6 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
ignore:
|
|
||||||
# Major updates for react-admin have breaking changes
|
|
||||||
- dependency-name: "react-admin"
|
|
||||||
update-types: ["version-update:semver-major"]
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
|
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@v3
|
||||||
|
- name: Login to ghcr.io
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
registry.etke.cc/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||||
|
type=semver,pattern={{raw}}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
cdn:
|
||||||
|
name: CDN
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
- name: Upload
|
||||||
|
run: |
|
||||||
|
wget -O bunny-upload.tar.gz https://github.com/etkecc/bunny-upload/releases/download/${{ env.bunny_version }}/bunny-upload_Linux_x86_64.tar.gz
|
||||||
|
tar -xzf bunny-upload.tar.gz
|
||||||
|
echo "${{ secrets.BUNNY_CONFIG }}" > bunny-config.yaml
|
||||||
|
./bunny-upload -c bunny-config.yaml
|
||||||
|
|
||||||
|
github-release:
|
||||||
|
name: Github Release
|
||||||
|
needs: build
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
mv dist synapse-admin
|
||||||
|
tar chvzf synapse-admin.tar.gz synapse-admin
|
||||||
|
- uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: synapse-admin.tar.gz
|
||||||
|
generate_release_notes: true
|
||||||
|
make_latest: "true"
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -191,3 +191,6 @@ sketch
|
|||||||
# .pnp.*
|
# .pnp.*
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode
|
||||||
|
|
||||||
|
/testdata/synapse.data
|
||||||
|
/testdata/postgres.data
|
||||||
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -5,6 +5,6 @@
|
|||||||
},
|
},
|
||||||
"eslint.nodePath": ".yarn/sdks",
|
"eslint.nodePath": ".yarn/sdks",
|
||||||
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
|
||||||
"typescript.tsdk": ".yarn/sdks/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
}
|
}
|
||||||
|
5
.watchmanconfig
Normal file
5
.watchmanconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"ignore_dirs": [
|
||||||
|
"testdata"
|
||||||
|
]
|
||||||
|
}
|
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 +0,0 @@
|
|||||||
yarnPath: .yarn/releases/yarn-4.4.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
|
|
||||||
|
276
README.md
276
README.md
@@ -1,92 +1,103 @@
|
|||||||
[](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
# Synapse Admin UI [](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
|
||||||
[](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
|
|
||||||
[](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
|
|
||||||
[](https://awesome-technologies.github.io/synapse-admin/)
|
|
||||||
[](https://hub.docker.com/r/awesometechnologies/synapse-admin)
|
|
||||||
[](https://github.com/Awesome-Technologies/synapse-admin/releases)
|
|
||||||
|
|
||||||
# Synapse admin ui
|

|
||||||
|
|
||||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||||
|
|
||||||
## Usage
|
<!-- vim-markdown-toc GFM -->
|
||||||
|
|
||||||
### Supported Synapse
|
* [Fork differences](#fork-differences)
|
||||||
|
* [Availability](#availability)
|
||||||
|
* [Changes](#changes)
|
||||||
|
* [Development](#development)
|
||||||
|
* [Configuration](#configuration)
|
||||||
|
* [Restricting available homeserver](#restricting-available-homeserver)
|
||||||
|
* [Protecting appservice managed users](#protecting-appservice-managed-users)
|
||||||
|
* [Adding custom menu items](#adding-custom-menu-items)
|
||||||
|
* [Providing support URL](#providing-support-url)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Supported Synapse](#supported-synapse)
|
||||||
|
* [Prerequisites](#prerequisites)
|
||||||
|
* [Use without install](#use-without-install)
|
||||||
|
* [Step-By-Step install](#step-by-step-install)
|
||||||
|
* [Steps for 1)](#steps-for-1)
|
||||||
|
* [Steps for 2)](#steps-for-2)
|
||||||
|
* [Steps for 3)](#steps-for-3)
|
||||||
|
* [Serving Synapse-Admin on a different path](#serving-synapse-admin-on-a-different-path)
|
||||||
|
* [Development](#development-1)
|
||||||
|
|
||||||
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected!
|
<!-- vim-markdown-toc -->
|
||||||
|
|
||||||
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
## Fork differences
|
||||||
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
|
||||||
|
|
||||||
After entering the URL on the login page of synapse-admin the server version appears below the input field.
|
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.
|
||||||
|
|
||||||
### Prerequisites
|
### Availability
|
||||||
|
|
||||||
You need access to the following endpoints:
|
* As a core/default component on [etke.cc](https://etke.cc/?utm_source=github&utm_medium=readme&utm_campaign=synapse-admin)
|
||||||
|
* Via CDN on [admin.etke.cc](https://admin.etke.cc)
|
||||||
|
* As a component in [Matrix-Docker-Ansible-Deploy Playbook](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-synapse-admin.md)
|
||||||
|
|
||||||
- `/_matrix`
|
### Changes
|
||||||
- `/_synapse/admin`
|
|
||||||
|
|
||||||
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
|
The following changes are already implemented:
|
||||||
|
|
||||||
### Use without install
|
* [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)
|
||||||
|
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
|
||||||
|
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
|
||||||
|
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
|
||||||
|
* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
|
||||||
|
* [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53)
|
||||||
|
* [Login with access token](https://github.com/etkecc/synapse-admin/pull/58)
|
||||||
|
* [Fix footer causing vertical scrollbar](https://github.com/etkecc/synapse-admin/pull/60)
|
||||||
|
* [Custom Menu Items](https://github.com/etkecc/synapse-admin/pull/79)
|
||||||
|
* [Add user profile to the top menu](https://github.com/etkecc/synapse-admin/pull/80)
|
||||||
|
|
||||||
You can use the current version of Synapse Admin without own installation direct
|
_the list will be updated as new changes are added_
|
||||||
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/).
|
|
||||||
|
|
||||||
**Note:**
|
### Development
|
||||||
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
|
||||||
**Remember: You have no need to expose these endpoints to the internet but to your network.**
|
|
||||||
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
|
|
||||||
|
|
||||||
### Step-By-Step install
|
`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)
|
||||||
|
|
||||||
You have three options:
|
After that open `http://localhost:5173` in your browser, login using the following credentials:
|
||||||
|
|
||||||
1. [Download the tarball and serve with any webserver](#steps-for-1)
|
* Login: admin
|
||||||
2. [Download the source code from github and run using nodejs](#steps-for-2)
|
* Password: admin
|
||||||
3. [Run the Docker container](#steps-for-3)
|
* Homeserver URL: http://localhost:8008
|
||||||
|
|
||||||
#### Steps for 1)
|
## Configuration
|
||||||
|
|
||||||
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
You can use `config.json` file to configure synapse-admin
|
||||||
- configure a vhost for synapse admin on your webserver
|
|
||||||
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest
|
|
||||||
- unpack the .tar.gz
|
|
||||||
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir
|
|
||||||
- open the url of the vhost in your browser
|
|
||||||
|
|
||||||
#### Steps for 2)
|
The `config.json` can be injected into a Docker container using a bind mount.
|
||||||
|
|
||||||
- make sure you have installed the following: git, yarn, nodejs
|
```yml
|
||||||
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
|
services:
|
||||||
- change into downloaded directory: `cd synapse-admin`
|
|
||||||
- download dependencies: `yarn install`
|
|
||||||
- start web server: `yarn start`
|
|
||||||
|
|
||||||
#### Steps for 3)
|
|
||||||
|
|
||||||
- run the Docker container from the public docker registry: `docker run -p 8080:80 awesometechnologies/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
|
|
||||||
|
|
||||||
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
|
|
||||||
|
|
||||||
```yml
|
|
||||||
services:
|
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
container_name: synapse-admin
|
...
|
||||||
hostname: synapse-admin
|
volumes:
|
||||||
build:
|
./config.json:/app/config.json:ro
|
||||||
context: https://github.com/Awesome-Technologies/synapse-admin.git
|
...
|
||||||
args:
|
```
|
||||||
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
|
||||||
# - NODE_OPTIONS="--max_old_space_size=1024"
|
|
||||||
# - BASE_PATH="/synapse-admin"
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
- browse to http://localhost:8080
|
|
||||||
|
|
||||||
### Restricting available homeserver
|
### Restricting available homeserver
|
||||||
|
|
||||||
@@ -108,17 +119,128 @@ or to a list of homeservers:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `config.json` can be injected into a Docker container using a bind mount.
|
### Protecting appservice managed users
|
||||||
|
|
||||||
```yml
|
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
|
||||||
services:
|
you can specify the list of MXIDs (regexp) that should be prohibited from any changes, except display name and avatar.
|
||||||
synapse-admin:
|
|
||||||
...
|
Example for [mautrix-telegram](https://github.com/mautrix/telegram)
|
||||||
volumes:
|
|
||||||
./config.json:/app/config.json:ro
|
```json
|
||||||
...
|
{
|
||||||
|
"asManagedUsers": ["^@telegram_[a-zA-Z0-9]+:example\\.com$"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Adding custom menu items
|
||||||
|
|
||||||
|
You can add custom menu items to the main menu by providing a `menu` array in the `config.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"menu": [
|
||||||
|
{
|
||||||
|
"label": "Contact support",
|
||||||
|
"icon": "SupportAgent",
|
||||||
|
"url": "https://github.com/etkecc/synapse-admin/issues"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `icon` is one of the [preloaded icons](./src/components/icons.ts)
|
||||||
|
|
||||||
|
### Providing support URL
|
||||||
|
|
||||||
|
**Deprecated**: use `menu` config option described above. Automatically migrated to the `menu` if the `supportURL` is present.
|
||||||
|
|
||||||
|
~~Synapse-Admin provides a support link in the main menu - `Contact support`. By default, the link points to the GitHub issues page of the project. You can change this link by providing a `supportURL` in the `config.json`.~~
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"supportURL": "https://example.com/support"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Supported Synapse
|
||||||
|
|
||||||
|
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.116.0 for all functions to work as expected!
|
||||||
|
|
||||||
|
You get your server version with the request `/_synapse/admin/v1/server_version`.
|
||||||
|
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
|
||||||
|
|
||||||
|
After entering the URL on the login page of synapse-admin the server version appears below the input field.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
You need access to the following endpoints:
|
||||||
|
|
||||||
|
- `/_matrix`
|
||||||
|
- `/_synapse/admin`
|
||||||
|
|
||||||
|
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
|
||||||
|
|
||||||
|
### Use without install
|
||||||
|
|
||||||
|
You can use the current version of Synapse Admin without own installation direct
|
||||||
|
via [admin.etke.cc](https://admin.etke.cc).
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
|
||||||
|
**Remember: You have no need to expose these endpoints to the internet but to your network.**
|
||||||
|
If you want your own deployment, follow the [Step-By-Step Install Guide](#step-by-step-install) below.
|
||||||
|
|
||||||
|
### Step-By-Step install
|
||||||
|
|
||||||
|
You have three options:
|
||||||
|
|
||||||
|
1. [Download the tarball and serve with any webserver](#steps-for-1)
|
||||||
|
2. [Download the source code from github and run using nodejs](#steps-for-2)
|
||||||
|
3. [Run the Docker container](#steps-for-3)
|
||||||
|
|
||||||
|
#### Steps for 1)
|
||||||
|
|
||||||
|
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
|
||||||
|
- configure a vhost for synapse admin on your webserver
|
||||||
|
- download the .tar.gz [from the latest release](https://github.com/etkecc/synapse-admin/releases/latest)
|
||||||
|
- unpack the .tar.gz
|
||||||
|
- move or symlink the `synapse-admin` into your vhosts root dir
|
||||||
|
- open the url of the vhost in your browser
|
||||||
|
|
||||||
|
#### Steps for 2)
|
||||||
|
|
||||||
|
- make sure you have installed the following: git, yarn, nodejs
|
||||||
|
- download the source code: `git clone https://github.com/etkecc/synapse-admin.git`
|
||||||
|
- change into downloaded directory: `cd synapse-admin`
|
||||||
|
- download dependencies: `yarn install`
|
||||||
|
- start web server: `yarn start`
|
||||||
|
|
||||||
|
#### Steps for 3)
|
||||||
|
|
||||||
|
- run the Docker container from the public docker registry: `docker run -p 8080:80 ghcr.io/etkecc/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
|
||||||
|
|
||||||
|
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail.
|
||||||
|
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
synapse-admin:
|
||||||
|
container_name: synapse-admin
|
||||||
|
hostname: synapse-admin
|
||||||
|
build:
|
||||||
|
context: https://github.com/etkecc/synapse-admin.git
|
||||||
|
args:
|
||||||
|
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
|
# - NODE_OPTIONS="--max_old_space_size=1024"
|
||||||
|
# - BASE_PATH="/synapse-admin"
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
- browse to http://localhost:8080
|
||||||
|
|
||||||
### Serving Synapse-Admin on a different path
|
### Serving Synapse-Admin on a different path
|
||||||
|
|
||||||
The path prefix where synapse-admin is served can only be changed during the build step.
|
The path prefix where synapse-admin is served can only be changed during the build step.
|
||||||
@@ -145,7 +267,7 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
synapse-admin:
|
synapse-admin:
|
||||||
image: awesometechnologies/synapse-admin:latest
|
image: etkecc/synapse-admin:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
@@ -156,10 +278,6 @@ services:
|
|||||||
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE
|
||||||
|
20
docker-compose-dev.yml
Normal file
20
docker-compose-dev.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
entrypoint: python
|
||||||
|
command: "-m synapse.app.homeserver -c /config/homeserver.yaml"
|
||||||
|
ports:
|
||||||
|
- "8008:8008"
|
||||||
|
volumes:
|
||||||
|
- ./testdata/synapse:/config
|
||||||
|
- ./testdata/synapse.data:/media-store
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:alpine
|
||||||
|
volumes:
|
||||||
|
- ./testdata/postgres.data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: synapse
|
||||||
|
POSTGRES_PASSWORD: synapse
|
||||||
|
POSTGRES_DB: synapse
|
||||||
|
POSTGRES_INITDB_ARGS: "--lc-collate C --lc-ctype C --encoding UTF8"
|
@@ -2,13 +2,13 @@ services:
|
|||||||
synapse-admin:
|
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
|
||||||
|
15
index.html
15
index.html
@@ -22,6 +22,11 @@
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
min-height: 90vh !important;
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
.loader-container {
|
.loader-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -120,13 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
<footer
|
<span id="js-version" style="display: none;"></span>
|
||||||
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="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
|
|
||||||
Synapse-Admin <b><span id="version"></span></b> by Awesome Technologies Innovationslabor GmbH
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
<script>document.getElementById("js-version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
|
||||||
</html>
|
</html>
|
||||||
|
44
justfile
Normal file
44
justfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 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 a user in the dev stack
|
||||||
|
register-user localpart password *admin:
|
||||||
|
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
|
||||||
|
|
||||||
|
# run yarn {fix,lint,test} commands
|
||||||
|
test:
|
||||||
|
@-yarn run fix
|
||||||
|
@-yarn run lint
|
||||||
|
@-yarn run test
|
||||||
|
|
||||||
|
# run the app in a production mode
|
||||||
|
run-prod: build
|
||||||
|
@python -m http.server -d dist 1313
|
||||||
|
|
||||||
|
# install the project
|
||||||
|
__install:
|
||||||
|
@yarn install --immutable --network-timeout=300000
|
60
package.json
60
package.json
@@ -3,74 +3,72 @@
|
|||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
"author": "etke.cc (originally by Awesome Technologies Innovationslabor GmbH)",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"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",
|
|
||||||
"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.6.2",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^22.7.7",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.15",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
"@typescript-eslint/eslint-plugin": "^8.10.0",
|
||||||
"@typescript-eslint/parser": "^7.16.1",
|
"@typescript-eslint/parser": "^8.10.0",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"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",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"ts-jest": "^29.2.3",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.32.1",
|
"typescript-eslint": "^8.10.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^5.4.6",
|
||||||
"vite-plugin-version-mark": "^0.1.0"
|
"vite-plugin-version-mark": "^0.1.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@haleos/ra-language-german": "^1.0.0",
|
"@haleos/ra-language-german": "^1.0.0",
|
||||||
"@haxqer/ra-language-chinese": "^4.16.2",
|
"@haxqer/ra-language-chinese": "^4.16.2",
|
||||||
"@mui/icons-material": "^5.16.4",
|
"@mui/icons-material": "^6.1.1",
|
||||||
"@mui/material": "^5.16.4",
|
"@mui/material": "^6.1.1",
|
||||||
"@tanstack/react-query": "^5.59.12",
|
"@mui/utils": "^5.16.6",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"query-string": "^7.1.3",
|
"ra-core": "^5.3.0",
|
||||||
"ra-core": "^5.2.3",
|
"ra-i18n-polyglot": "^5.3.0",
|
||||||
"ra-i18n-polyglot": "^5.2.3",
|
"ra-language-english": "^5.3.0",
|
||||||
"ra-language-english": "^5.8.2",
|
|
||||||
"ra-language-farsi": "^5.0.0",
|
"ra-language-farsi": "^5.0.0",
|
||||||
"ra-language-french": "^5.2.3",
|
"ra-language-french": "^5.2.0",
|
||||||
"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": "^5.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.52.1",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-router": "^6.28.1",
|
"react-router": "^6.26.2",
|
||||||
"react-router-dom": "^6.28.1"
|
"react-router-dom": "^6.27.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite serve",
|
"start": "vite serve",
|
||||||
|
@@ -4,6 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
|
|||||||
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
|
||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AdminLayout } from "./components/AdminLayout";
|
||||||
import { ImportFeature } from "./components/ImportFeature";
|
import { ImportFeature } from "./components/ImportFeature";
|
||||||
import germanMessages from "./i18n/de";
|
import germanMessages from "./i18n/de";
|
||||||
import englishMessages from "./i18n/en";
|
import englishMessages from "./i18n/en";
|
||||||
@@ -21,6 +22,8 @@ import userMediaStats from "./resources/user_media_statistics";
|
|||||||
import users from "./resources/users";
|
import users from "./resources/users";
|
||||||
import authProvider from "./synapse/authProvider";
|
import authProvider from "./synapse/authProvider";
|
||||||
import dataProvider from "./synapse/dataProvider";
|
import dataProvider from "./synapse/dataProvider";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import Footer from "./components/Footer";
|
||||||
|
|
||||||
// TODO: Can we use lazy loading together with browser locale?
|
// TODO: Can we use lazy loading together with browser locale?
|
||||||
const messages = {
|
const messages = {
|
||||||
@@ -45,10 +48,14 @@ const i18nProvider = polyglotI18nProvider(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<Admin
|
<Admin
|
||||||
disableTelemetry
|
disableTelemetry
|
||||||
requireAuth
|
requireAuth
|
||||||
|
layout={AdminLayout}
|
||||||
loginPage={LoginPage}
|
loginPage={LoginPage}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
@@ -75,6 +82,8 @@ const App = () => (
|
|||||||
<Resource name="room_state" />
|
<Resource name="room_state" />
|
||||||
<Resource name="destination_rooms" />
|
<Resource name="destination_rooms" />
|
||||||
</Admin>
|
</Admin>
|
||||||
|
<Footer />
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@@ -2,6 +2,15 @@ import { createContext, useContext } from "react";
|
|||||||
|
|
||||||
interface AppContextType {
|
interface AppContextType {
|
||||||
restrictBaseUrl: string | string[];
|
restrictBaseUrl: string | string[];
|
||||||
|
asManagedUsers: string[];
|
||||||
|
supportURL: string;
|
||||||
|
menu: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppContext = createContext({});
|
export const AppContext = createContext({});
|
||||||
|
98
src/components/AdminLayout.tsx
Normal file
98
src/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { AppBar, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin";
|
||||||
|
import { LoginMethod } from "../pages/LoginPage";
|
||||||
|
import { useEffect, useState, Suspense } from "react";
|
||||||
|
import { Icons, DefaultIcon } from "./icons";
|
||||||
|
|
||||||
|
const AdminUserMenu = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const logout = useLogout();
|
||||||
|
const checkLoginType = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod;
|
||||||
|
if (loginType === "accessToken") {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("login_type");
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserMenu>
|
||||||
|
<div onClickCapture={checkLoginType}>
|
||||||
|
<Logout />
|
||||||
|
</div>
|
||||||
|
<Confirm
|
||||||
|
isOpen={open}
|
||||||
|
title="synapseadmin.auth.logout_acces_token_dialog.title"
|
||||||
|
content="synapseadmin.auth.logout_acces_token_dialog.content"
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
confirm="synapseadmin.auth.logout_acces_token_dialog.confirm"
|
||||||
|
cancel="synapseadmin.auth.logout_acces_token_dialog.cancel"
|
||||||
|
/>
|
||||||
|
</UserMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminAppBar = () => <AppBar userMenu={<AdminUserMenu />} />;
|
||||||
|
|
||||||
|
const AdminMenu = (props) => {
|
||||||
|
const [menu, setMenu] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const menuConfig = localStorage.getItem('menu');
|
||||||
|
if (menuConfig) {
|
||||||
|
try {
|
||||||
|
setMenu(JSON.parse(menuConfig));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing menu configuration', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu {...props}>
|
||||||
|
<Menu.ResourceItems />
|
||||||
|
{menu.map((item, index) => {
|
||||||
|
const { url, icon, label } = item;
|
||||||
|
const IconComponent = Icons[icon] as React.ComponentType<any> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense key={index}>
|
||||||
|
<Menu.Item
|
||||||
|
to={url}
|
||||||
|
target="_blank"
|
||||||
|
primaryText={label}
|
||||||
|
leftIcon={IconComponent ? <IconComponent /> : <DefaultIcon />}
|
||||||
|
onClick={props.onMenuClick}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminLayout = ({ children }) => (
|
||||||
|
<Layout appBar={AdminAppBar} menu={AdminMenu} sx={{
|
||||||
|
['& .RaLayout-appFrame']: {
|
||||||
|
minHeight: '90vh',
|
||||||
|
height: '90vh',
|
||||||
|
},
|
||||||
|
['& .RaLayout-content']: {
|
||||||
|
marginBottom: '3rem',
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Layout>
|
||||||
|
);
|
@@ -1,18 +1,43 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { RecordContextProvider } from "react-admin";
|
import { RecordContextProvider } from "react-admin";
|
||||||
|
import { act } from "react";
|
||||||
import AvatarField from "./AvatarField";
|
import AvatarField from "./AvatarField";
|
||||||
|
|
||||||
describe("AvatarField", () => {
|
describe("AvatarField", () => {
|
||||||
it("shows image", () => {
|
beforeEach(() => {
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
|
||||||
|
})
|
||||||
|
) as jest.Mock;
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL
|
||||||
|
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only("shows image", async () => {
|
||||||
const value = {
|
const value = {
|
||||||
avatar: "foo",
|
avatar: "mxc://serverName/mediaId",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
render(
|
render(
|
||||||
<RecordContextProvider value={value}>
|
<RecordContextProvider value={value}>
|
||||||
<AvatarField source="avatar" />
|
<AvatarField source="avatar" />
|
||||||
</RecordContextProvider>
|
</RecordContextProvider>
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const img = screen.getByRole("img");
|
||||||
|
expect(img.getAttribute("src")).toBe("mock-object-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,12 +1,36 @@
|
|||||||
import { get } from "lodash";
|
import { get } from "lodash";
|
||||||
|
import { Avatar, AvatarProps } from "@mui/material";
|
||||||
|
import { FieldProps, useRecordContext } from "react-admin";
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
import { Avatar } from "@mui/material";
|
const AvatarField = ({ source, ...rest }: AvatarProps & FieldProps) => {
|
||||||
import { useRecordContext } from "react-admin";
|
|
||||||
|
|
||||||
const AvatarField = ({ source, ...rest }) => {
|
|
||||||
const record = useRecordContext(rest);
|
|
||||||
const src = get(record, source)?.toString();
|
|
||||||
const { alt, classes, sizes, sx, variant } = rest;
|
const { alt, classes, sizes, sx, variant } = rest;
|
||||||
|
const record = useRecordContext(rest);
|
||||||
|
const mxcURL = get(record, source)?.toString();
|
||||||
|
|
||||||
|
const [src, setSrc] = useState<string>("");
|
||||||
|
|
||||||
|
const fetchAvatar = useCallback(async (mxcURL: string) => {
|
||||||
|
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobURL = URL.createObjectURL(blob);
|
||||||
|
setSrc(blobURL);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mxcURL) {
|
||||||
|
fetchAvatar(mxcURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function to revoke the object URL
|
||||||
|
return () => {
|
||||||
|
if (src) {
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mxcURL, fetchAvatar]);
|
||||||
|
|
||||||
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
|
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
104
src/components/DeleteRoomButton.tsx
Normal file
104
src/components/DeleteRoomButton.tsx
Normal 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(false);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
|
||||||
|
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||||
|
const unselectAll = useUnselectAll(resourceName);
|
||||||
|
const recordIds = props.selectedIds;
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleDelete = (values: {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={false}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||||
|
{translate("ra.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={false}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||||
|
autoFocus
|
||||||
|
startIcon={<ActionCheck />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteRoomButton;
|
117
src/components/DeleteUserButton.tsx
Normal file
117
src/components/DeleteUserButton.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
|
||||||
|
import ActionDelete from "@mui/icons-material/Delete";
|
||||||
|
import ActionCheck from "@mui/icons-material/CheckCircle";
|
||||||
|
import AlertError from "@mui/icons-material/ErrorOutline";
|
||||||
|
|
||||||
|
interface DeleteUserButtonProps {
|
||||||
|
selectedIds: Identifier[];
|
||||||
|
confirmTitle: string;
|
||||||
|
confirmContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName = "users";
|
||||||
|
|
||||||
|
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [deleteMedia, setDeleteMedia] = useState(false);
|
||||||
|
const [redactEvents, setRedactEvents] = useState(false);
|
||||||
|
|
||||||
|
const notify = useNotify();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
|
||||||
|
const [deleteMany, { isLoading }] = useDeleteMany();
|
||||||
|
const unselectAll = useUnselectAll(resourceName);
|
||||||
|
const recordIds = props.selectedIds;
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
|
||||||
|
deleteMany(
|
||||||
|
resourceName,
|
||||||
|
{ ids: recordIds, meta: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("ra.notification.deleted", {
|
||||||
|
messageArgs: {
|
||||||
|
smart_count: recordIds.length,
|
||||||
|
},
|
||||||
|
type: 'info' as NotificationType,
|
||||||
|
});
|
||||||
|
handleDialogClose();
|
||||||
|
unselectAll();
|
||||||
|
redirect("/users");
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setOpen(false);
|
||||||
|
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={"ra-delete-button"}
|
||||||
|
key="button"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"&.MuiButton-sizeSmall": {
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
color={"error"}
|
||||||
|
startIcon={<ActionDelete />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.delete")}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onClose={handleDialogClose}>
|
||||||
|
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
|
||||||
|
<SimpleForm toolbar={false}>
|
||||||
|
<BooleanInput
|
||||||
|
source="deleteMedia"
|
||||||
|
value={deleteMedia}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
|
||||||
|
label="resources.users.action.delete_media"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
<BooleanInput
|
||||||
|
source="redactEvents"
|
||||||
|
value={redactEvents}
|
||||||
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
|
||||||
|
label="resources.users.action.redact_events"
|
||||||
|
defaultValue={false}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
|
||||||
|
{translate("ra.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={false}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={"ra-confirm RaConfirm-confirmPrimary"}
|
||||||
|
autoFocus
|
||||||
|
startIcon={<ActionCheck />}
|
||||||
|
>
|
||||||
|
{translate("ra.action.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteUserButton;
|
40
src/components/Footer.tsx
Normal file
40
src/components/Footer.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Box, Link, Typography } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const version = document.getElementById("js-version")?.textContent;
|
||||||
|
if (version) {
|
||||||
|
setVersion(version);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 100,
|
||||||
|
bottom: 0,
|
||||||
|
width: '100%',
|
||||||
|
bgcolor: "#eee",
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: '#ddd',
|
||||||
|
p: 1,
|
||||||
|
}}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
|
||||||
|
Synapse-Admin
|
||||||
|
</Link> <Link href={`https://github.com/etkecc/synapse-admin/releases/tag/`+version} target="_blank">
|
||||||
|
<span style={{ fontWeight: 'bold', color: "#000" }}>{version}</span>
|
||||||
|
</Link> <Link sx={{ color: "#888", textDecoration: 'none' }} href="https://etke.cc/?utm_source=synapse-admin&utm_medium=footer&utm_campaign=synapse-admin" target="_blank">
|
||||||
|
by etke.cc
|
||||||
|
</Link> <Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/awesome-technologies/synapse-admin" target="_blank">
|
||||||
|
(originally developed by Awesome Technologies Innovationslabor GmbH)
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
@@ -15,7 +15,7 @@ import {
|
|||||||
import { DataProvider, useTranslate } from "ra-core";
|
import { DataProvider, useTranslate } from "ra-core";
|
||||||
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
|
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin";
|
||||||
|
|
||||||
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
|
import { generateRandomMxId, generateRandomPassword, returnMXID } from "../synapse/synapse";
|
||||||
|
|
||||||
const LOGGING = true;
|
const LOGGING = true;
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ const FilePicker = () => {
|
|||||||
|
|
||||||
const [conflictMode, setConflictMode] = useState("stop");
|
const [conflictMode, setConflictMode] = useState("stop");
|
||||||
const [passwordMode, setPasswordMode] = useState(true);
|
const [passwordMode, setPasswordMode] = useState(true);
|
||||||
const [useridMode, setUseridMode] = useState("ignore");
|
const [useridMode, setUseridMode] = useState("update");
|
||||||
|
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
@@ -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] }));
|
||||||
@@ -262,12 +262,15 @@ const FilePicker = () => {
|
|||||||
const userRecord = { ...entry };
|
const userRecord = { ...entry };
|
||||||
// No need to do a bunch of cryptographic random number getting if
|
// No need to do a bunch of cryptographic random number getting if
|
||||||
// we are using neither a generated password nor a generated user id.
|
// 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();
|
userRecord.id = generateRandomMxId();
|
||||||
}
|
}
|
||||||
if (passwordMode === false || entry.password === undefined) {
|
if (passwordMode === false || entry.password === undefined || entry.password === "") {
|
||||||
userRecord.password = generateRandomPassword();
|
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, ... */
|
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
|
||||||
|
|
||||||
/* For these modes we will consider the ID that's in the record.
|
/* For these modes we will consider the ID that's in the record.
|
||||||
|
58
src/components/LoginFormBox.tsx
Normal file
58
src/components/LoginFormBox.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
const LoginFormBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: "calc(100vh - 1rem)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
background: "url(./images/floating-cogs.svg)",
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: "cover",
|
||||||
|
|
||||||
|
[`& .card`]: {
|
||||||
|
width: "30rem",
|
||||||
|
marginTop: "6rem",
|
||||||
|
marginBottom: "6rem",
|
||||||
|
},
|
||||||
|
[`& .avatar`]: {
|
||||||
|
margin: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
[`& .icon`]: {
|
||||||
|
backgroundColor: theme.palette.grey[500],
|
||||||
|
},
|
||||||
|
[`& .hint`]: {
|
||||||
|
marginTop: "1em",
|
||||||
|
marginBottom: "1em",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: theme.palette.grey[600],
|
||||||
|
},
|
||||||
|
[`& .form`]: {
|
||||||
|
padding: "0 1rem 1rem 1rem",
|
||||||
|
},
|
||||||
|
[`& .select`]: {
|
||||||
|
marginBottom: "2rem",
|
||||||
|
},
|
||||||
|
[`& .actions`]: {
|
||||||
|
padding: "0 1rem 1rem 1rem",
|
||||||
|
},
|
||||||
|
[`& .serverVersion`]: {
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
},
|
||||||
|
[`& .matrixVersions`]: {
|
||||||
|
color: theme.palette.grey[500],
|
||||||
|
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default LoginFormBox;
|
@@ -64,7 +64,7 @@ export const ServerNoticeButton = () => {
|
|||||||
const handleDialogClose = () => setOpen(false);
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = (values: Partial<RaRecord>) => {
|
const handleSend = (values: Partial<RaRecord>) => {
|
||||||
|
@@ -1,9 +1,15 @@
|
|||||||
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
|
||||||
|
import { isASManaged } from "./mxid";
|
||||||
|
|
||||||
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
|
|
||||||
|
let isASManagedUser = false;
|
||||||
|
if (record.user_id) {
|
||||||
|
isASManagedUser = isASManaged(record.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeleteWithConfirmButton
|
<DeleteWithConfirmButton
|
||||||
{...props}
|
{...props}
|
||||||
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
|
|||||||
confirmContent="resources.devices.action.erase.content"
|
confirmContent="resources.devices.action.erase.content"
|
||||||
mutationMode="pessimistic"
|
mutationMode="pessimistic"
|
||||||
redirect={false}
|
redirect={false}
|
||||||
|
disabled={isASManagedUser}
|
||||||
translateOptions={{
|
translateOptions={{
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.display_name ? record.display_name : record.id,
|
name: record.display_name ? record.display_name : record.id,
|
||||||
|
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}`;
|
12
src/components/icons.ts
Normal file
12
src/components/icons.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { lazy } from "react";
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
Announcement: lazy(() => import('@mui/icons-material/Announcement')),
|
||||||
|
Engineering: lazy(() => import('@mui/icons-material/Engineering')),
|
||||||
|
HelpCenter: lazy(() => import('@mui/icons-material/HelpCenter')),
|
||||||
|
SupportAgent: lazy(() => import('@mui/icons-material/SupportAgent')),
|
||||||
|
Default: lazy(() => import('@mui/icons-material/OpenInNew')),
|
||||||
|
// Add more icons as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultIcon = Icons.Default;
|
@@ -8,7 +8,9 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
|||||||
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
import FileOpenIcon from "@mui/icons-material/FileOpen";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import DownloadingIcon from '@mui/icons-material/Downloading';
|
||||||
|
import { Grid2 as Grid, Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
import {
|
import {
|
||||||
BooleanInput,
|
BooleanInput,
|
||||||
@@ -29,12 +31,11 @@ import {
|
|||||||
useTranslate,
|
useTranslate,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { dateParser } from "./date";
|
import { dateParser } from "./date";
|
||||||
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
import { getMediaUrl } from "../synapse/synapse";
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
@@ -311,48 +312,104 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ViewMediaButton = ({ media_id, label }) => {
|
export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
const url = getMediaUrl(media_id);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const isImage = mimetype && mimetype.startsWith("image/");
|
||||||
|
|
||||||
|
const openFileInNewTab = (blobURL: string) => {
|
||||||
|
const anchorElement = document.createElement("a");
|
||||||
|
anchorElement.href = blobURL;
|
||||||
|
anchorElement.target = "_blank";
|
||||||
|
document.body.appendChild(anchorElement);
|
||||||
|
anchorElement.click();
|
||||||
|
document.body.removeChild(anchorElement);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async (blobURL: string) => {
|
||||||
|
console.log("downloadFile", blobURL, uploadName);
|
||||||
|
const anchorElement = document.createElement("a");
|
||||||
|
anchorElement.href = blobURL;
|
||||||
|
anchorElement.download = uploadName;
|
||||||
|
document.body.appendChild(anchorElement);
|
||||||
|
anchorElement.click();
|
||||||
|
document.body.removeChild(anchorElement);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobURL), 10);;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (preview: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetchAuthenticatedMedia(mxcURL, "original");
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobURL = URL.createObjectURL(blob);
|
||||||
|
if (preview) {
|
||||||
|
openFileInNewTab(blobURL);
|
||||||
|
} else {
|
||||||
|
downloadFile(blobURL);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ whiteSpace: "pre" }}>
|
<>
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
<Tooltip title={translate("resources.users_media.action.open")}>
|
<Tooltip title={translate("resources.users_media.action.open")}>
|
||||||
<span>
|
<span>
|
||||||
|
{isImage && (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
disabled={loading}
|
||||||
to={url}
|
onClick={() => handleFile(true)}
|
||||||
target="_blank"
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
rel="noopener"
|
|
||||||
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
|
|
||||||
>
|
>
|
||||||
<FileOpenIcon />
|
{loading ? <DownloadingIcon /> : <FileOpenIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{label}
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleFile(false)}
|
||||||
|
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{loading ? <DownloadingIcon /> : <DownloadIcon />}
|
||||||
|
</Button>
|
||||||
|
<span>{label}</span>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MediaIDField = ({ source }) => {
|
export const MediaIDField = ({ source }) => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const homeserver = storage.getItem("home_server");
|
const homeserver = storage.getItem("home_server");
|
||||||
const record = useRecordContext();
|
|
||||||
if (!record) return null;
|
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mediaID = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
if (!mediaID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
|
const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
|
||||||
|
const mxcURL = `mxc://${homeserver}/${mediaID}`;
|
||||||
|
|
||||||
|
return <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MXCField = ({ source }) => {
|
export const ReportMediaContent = ({ source }) => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
if (!record) return null;
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const src = get(record, source)?.toString();
|
const mxcURL = get(record, source)?.toString();
|
||||||
if (!src) return null;
|
if (!mxcURL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const media_id = src.replace("mxc://", "");
|
const uploadName = decodeURIComponent(get(record, "event_json.content.body")?.toString());
|
||||||
|
|
||||||
return <ViewMediaButton media_id={media_id} label={src} />;
|
return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type}/>;
|
||||||
};
|
};
|
||||||
|
16
src/components/mxid.tsx
Normal file
16
src/components/mxid.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Identifier } from "ra-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is managed by an application service
|
||||||
|
* @param id The user ID to check
|
||||||
|
* @returns Whether the user is managed by an application service
|
||||||
|
*/
|
||||||
|
export const isASManaged = (id: string | Identifier): boolean => {
|
||||||
|
const managedUsersString = localStorage.getItem("as_managed_users") || '';
|
||||||
|
try {
|
||||||
|
const asManagedUsers = JSON.parse(managedUsersString).map(regex => new RegExp(regex));
|
||||||
|
return asManagedUsers.some(regex => regex.test(id));
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
@@ -2,7 +2,7 @@ import { formalGermanMessages } from "@haleos/ra-language-german";
|
|||||||
|
|
||||||
import { SynapseTranslationMessages } from ".";
|
import { SynapseTranslationMessages } from ".";
|
||||||
|
|
||||||
const de: SynapseTranslationMessages = {
|
const fixedGermanMessages = {
|
||||||
...formalGermanMessages,
|
...formalGermanMessages,
|
||||||
ra: {
|
ra: {
|
||||||
...formalGermanMessages.ra,
|
...formalGermanMessages.ra,
|
||||||
@@ -10,8 +10,30 @@ const de: SynapseTranslationMessages = {
|
|||||||
...formalGermanMessages.ra.navigation,
|
...formalGermanMessages.ra.navigation,
|
||||||
no_filtered_results: "Keine Ergebnisse",
|
no_filtered_results: "Keine Ergebnisse",
|
||||||
clear_filters: "Alle Filter entfernen",
|
clear_filters: "Alle Filter entfernen",
|
||||||
|
add_filter: "Filter hinzufügen",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
...formalGermanMessages.ra.action,
|
||||||
|
update_application: "Anwendung aktualisieren",
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
...formalGermanMessages.ra.page,
|
||||||
|
empty: "Leer",
|
||||||
|
access_denied: "Zugriff verweigert",
|
||||||
|
authentication_error: "Authentifizierungsfehler",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
...formalGermanMessages.ra.message,
|
||||||
|
access_denied:
|
||||||
|
"Sie haben nicht die richtigen Berechtigungen um auf diese Seite zuzugreifen.",
|
||||||
|
authentication_error:
|
||||||
|
"Der Authentifizierungsserver hat einen Fehler zurückgegeben und Ihre Anmeldedaten konnten nicht überprüft werden.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const de: SynapseTranslationMessages = {
|
||||||
|
...fixedGermanMessages,
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Heimserver URL",
|
base_url: "Heimserver URL",
|
||||||
@@ -22,6 +44,14 @@ const de: SynapseTranslationMessages = {
|
|||||||
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
||||||
url_error: "Keine gültige Matrix Server URL",
|
url_error: "Keine gültige Matrix Server URL",
|
||||||
sso_sign_in: "Anmeldung mit SSO",
|
sso_sign_in: "Anmeldung mit SSO",
|
||||||
|
credentials: "Anmeldedaten",
|
||||||
|
access_token: "Zugriffstoken",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.",
|
||||||
|
content: "Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?",
|
||||||
|
confirm: "Sitzung beenden",
|
||||||
|
cancel: "Nur vom Admin-Panel abmelden",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.",
|
||||||
@@ -133,6 +163,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",
|
||||||
@@ -149,10 +180,16 @@ const de: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
|
||||||
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
|
||||||
erase: "DSGVO konformes Löschen der Benutzerdaten",
|
erase: "DSGVO konformes Löschen der Benutzerdaten.",
|
||||||
|
erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.",
|
||||||
|
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
|
||||||
|
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Lösche Benutzerdaten",
|
erase: "Lösche Benutzerdaten",
|
||||||
|
erase_avatar: "Avatar löschen",
|
||||||
|
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
|
||||||
|
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -205,6 +242,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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -14,6 +14,14 @@ const en: SynapseTranslationMessages = {
|
|||||||
protocol_error: "URL has to start with 'http://' or 'https://'",
|
protocol_error: "URL has to start with 'http://' or 'https://'",
|
||||||
url_error: "Not a valid Matrix server URL",
|
url_error: "Not a valid Matrix server URL",
|
||||||
sso_sign_in: "Sign in with SSO",
|
sso_sign_in: "Sign in with SSO",
|
||||||
|
credentials: "Credentials",
|
||||||
|
access_token: "Access token",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "You are using an existing Matrix access token.",
|
||||||
|
content: "Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just logout from the admin panel?",
|
||||||
|
confirm: "Destroy session",
|
||||||
|
cancel: "Just logout from admin panel",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
invalid_user_id: "Localpart of a Matrix user-id without homeserver.",
|
||||||
@@ -142,9 +150,15 @@ const en: SynapseTranslationMessages = {
|
|||||||
password: "Changing password will log user out of all sessions.",
|
password: "Changing password will log user out of all sessions.",
|
||||||
deactivate: "You must provide a password to re-activate an account.",
|
deactivate: "You must provide a password to re-activate an account.",
|
||||||
erase: "Mark the user as GDPR-erased",
|
erase: "Mark the user as GDPR-erased",
|
||||||
|
erase_text: "This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.",
|
||||||
|
erase_admin_error: "Deleting own user is not allowed.",
|
||||||
|
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Erase user data",
|
erase: "Erase user data",
|
||||||
|
erase_avatar: "Erase avatar",
|
||||||
|
delete_media: "Delete all media uploaded by the user(-s)",
|
||||||
|
redact_events: "Redact all events sent by the user(-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -197,6 +211,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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -13,6 +13,14 @@ const fa: SynapseTranslationMessages = {
|
|||||||
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
protocol_error: "URL باید با 'http://' یا 'https://' شروع شود",
|
||||||
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
url_error: "آدرس وارد شده یک سرور معتبر نیست",
|
||||||
sso_sign_in: "با SSO وارد شوید",
|
sso_sign_in: "با SSO وارد شوید",
|
||||||
|
credentials: "اعتبارنامه",
|
||||||
|
access_token: "توکن دسترسی",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.",
|
||||||
|
content: "آیا میخواهید این جلسه (که میتواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟",
|
||||||
|
confirm: "نابودی جلسه",
|
||||||
|
cancel: "فقط خروج از پنل مدیریت",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.",
|
||||||
@@ -120,6 +128,7 @@ const fa: SynapseTranslationMessages = {
|
|||||||
deactivated: "غیرفعال",
|
deactivated: "غیرفعال",
|
||||||
guests: "نمایش مهمانان",
|
guests: "نمایش مهمانان",
|
||||||
show_deactivated: "نمایش کاربران غیرفعال شده",
|
show_deactivated: "نمایش کاربران غیرفعال شده",
|
||||||
|
show_locked: "نمایش کاربران قفل شده",
|
||||||
user_id: "جستجوی کاربر",
|
user_id: "جستجوی کاربر",
|
||||||
displayname: "نام نمایشی",
|
displayname: "نام نمایشی",
|
||||||
password: "رمز عبور",
|
password: "رمز عبور",
|
||||||
@@ -137,9 +146,15 @@ const fa: SynapseTranslationMessages = {
|
|||||||
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
|
||||||
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
|
||||||
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
|
||||||
|
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
|
||||||
|
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
|
||||||
|
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "پاک کردن اطلاعات کاربر",
|
erase: "پاک کردن اطلاعات کاربر",
|
||||||
|
erase_avatar: "محو الصورة الرمزية",
|
||||||
|
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
|
||||||
|
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -13,6 +13,14 @@ const fr: SynapseTranslationMessages = {
|
|||||||
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
protocol_error: "L'URL doit commencer par « http:// » ou « https:// »",
|
||||||
url_error: "L'URL du serveur Matrix n'est pas valide",
|
url_error: "L'URL du serveur Matrix n'est pas valide",
|
||||||
sso_sign_in: "Se connecter avec l’authentification unique",
|
sso_sign_in: "Se connecter avec l’authentification unique",
|
||||||
|
credentials: "Identifiants",
|
||||||
|
access_token: "Jeton d'accès",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Vous utilisez un jeton d'accès Matrix existant.",
|
||||||
|
content: "Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?",
|
||||||
|
confirm: "Détruire la session",
|
||||||
|
cancel: "Se déconnecter simplement du panneau d'administration",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.",
|
||||||
@@ -124,6 +132,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,9 +148,15 @@ const fr: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
|
||||||
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
|
||||||
|
erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.",
|
||||||
|
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
|
||||||
|
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Effacer les données de l'utilisateur",
|
erase: "Effacer les données de l'utilisateur",
|
||||||
|
erase_avatar: "Effacer l'avatar",
|
||||||
|
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
|
||||||
|
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -194,6 +209,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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
19
src/i18n/index.d.ts
vendored
19
src/i18n/index.d.ts
vendored
@@ -11,6 +11,14 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
protocol_error: string;
|
protocol_error: string;
|
||||||
url_error: string;
|
url_error: string;
|
||||||
sso_sign_in: string;
|
sso_sign_in: string;
|
||||||
|
credentials: string;
|
||||||
|
access_token: string;
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
confirm: string;
|
||||||
|
cancel: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: string;
|
invalid_user_id: string;
|
||||||
@@ -138,9 +146,15 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
password?: string;
|
password?: string;
|
||||||
deactivate: string;
|
deactivate: string;
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_text: string;
|
||||||
|
erase_admin_error: string;
|
||||||
|
modify_managed_user_error: string;
|
||||||
};
|
};
|
||||||
action: {
|
action: {
|
||||||
erase: string;
|
erase: string;
|
||||||
|
erase_avatar: string;
|
||||||
|
delete_media: string;
|
||||||
|
redact_events: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -191,6 +205,11 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
|||||||
erase: {
|
erase: {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
fields: {
|
||||||
|
block: string;
|
||||||
|
},
|
||||||
|
success: string;
|
||||||
|
failure: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -13,6 +13,14 @@ const it: SynapseTranslationMessages = {
|
|||||||
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
|
protocol_error: "L'URL deve iniziare per 'http://' o 'https://'",
|
||||||
url_error: "URL del server Matrix non valido",
|
url_error: "URL del server Matrix non valido",
|
||||||
sso_sign_in: "Accedi con SSO",
|
sso_sign_in: "Accedi con SSO",
|
||||||
|
credentials: "Credenziali",
|
||||||
|
access_token: "Token di accesso",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Stai utilizzando un token di accesso Matrix esistente.",
|
||||||
|
content: "Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?",
|
||||||
|
confirm: "Distruggi sessione",
|
||||||
|
cancel: "Disconnetti solo dal pannello di amministrazione",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "ID utente non valido su questo homeserver.",
|
invalid_user_id: "ID utente non valido su questo homeserver.",
|
||||||
@@ -121,6 +129,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",
|
||||||
@@ -138,9 +147,15 @@ const it: SynapseTranslationMessages = {
|
|||||||
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
|
||||||
deactivate: "Devi fornire una password per riattivare l'account.",
|
deactivate: "Devi fornire una password per riattivare l'account.",
|
||||||
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
erase: "Constrassegna l'utente come cancellato dal GDPR",
|
||||||
|
erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
|
||||||
|
erase_admin_error: "Non è consentito eliminare il proprio utente.",
|
||||||
|
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Cancella i dati dell'utente",
|
erase: "Cancella i dati dell'utente",
|
||||||
|
erase_avatar: "Cancella l'avatar dell'utente",
|
||||||
|
delete_media: "Elimina tutti i media caricati dall'utente(-s)",
|
||||||
|
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -2,7 +2,7 @@ import russianMessages from "ra-language-russian";
|
|||||||
|
|
||||||
import { SynapseTranslationMessages } from ".";
|
import { SynapseTranslationMessages } from ".";
|
||||||
|
|
||||||
const ru: SynapseTranslationMessages = {
|
const fixedRussianMessages = {
|
||||||
...russianMessages,
|
...russianMessages,
|
||||||
ra: {
|
ra: {
|
||||||
...russianMessages.ra,
|
...russianMessages.ra,
|
||||||
@@ -11,7 +11,24 @@ const ru: SynapseTranslationMessages = {
|
|||||||
no_filtered_results: "Нет результатов",
|
no_filtered_results: "Нет результатов",
|
||||||
clear_filters: "Все фильтры сбросить",
|
clear_filters: "Все фильтры сбросить",
|
||||||
},
|
},
|
||||||
|
page: {
|
||||||
|
...russianMessages.ra.page,
|
||||||
|
empty: "Пусто",
|
||||||
|
access_denied: "Доступ запрещен",
|
||||||
|
authentication_error: "Ошибка аутентификации",
|
||||||
},
|
},
|
||||||
|
message: {
|
||||||
|
...russianMessages.ra.message,
|
||||||
|
access_denied:
|
||||||
|
"У вас нет прав доступа к этой странице.",
|
||||||
|
authentication_error:
|
||||||
|
"Сервер аутентификации вернул ошибку и не смог проверить ваши учетные данные.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ru: SynapseTranslationMessages = {
|
||||||
|
...fixedRussianMessages,
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "Адрес домашнего сервера",
|
base_url: "Адрес домашнего сервера",
|
||||||
@@ -22,6 +39,14 @@ const ru: SynapseTranslationMessages = {
|
|||||||
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
|
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
|
||||||
url_error: "Неверный адрес сервера Matrix",
|
url_error: "Неверный адрес сервера Matrix",
|
||||||
sso_sign_in: "Вход через SSO",
|
sso_sign_in: "Вход через SSO",
|
||||||
|
credentials: "Учетные данные",
|
||||||
|
access_token: "Токен доступа",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "Вы используете существующий токен доступа Matrix.",
|
||||||
|
content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?",
|
||||||
|
confirm: "Завершить сессию",
|
||||||
|
cancel: "Просто выйти из панели администрирования",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
|
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
|
||||||
@@ -141,6 +166,7 @@ const ru: SynapseTranslationMessages = {
|
|||||||
erased: "Удалён",
|
erased: "Удалён",
|
||||||
guests: "Показывать гостей",
|
guests: "Показывать гостей",
|
||||||
show_deactivated: "Показывать деактивированных",
|
show_deactivated: "Показывать деактивированных",
|
||||||
|
show_locked: "Показывать заблокированных",
|
||||||
user_id: "Поиск пользователя",
|
user_id: "Поиск пользователя",
|
||||||
displayname: "Отображаемое имя",
|
displayname: "Отображаемое имя",
|
||||||
password: "Пароль",
|
password: "Пароль",
|
||||||
@@ -158,9 +184,15 @@ const ru: SynapseTranslationMessages = {
|
|||||||
password: "Смена пароля завершит все сессии пользователя.",
|
password: "Смена пароля завершит все сессии пользователя.",
|
||||||
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
|
||||||
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
|
||||||
|
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
|
||||||
|
erase_admin_error: "Удаление собственного пользователя запрещено.",
|
||||||
|
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "Удалить данные пользователя",
|
erase: "Удалить данные пользователя",
|
||||||
|
erase_avatar: "Удалить аватар",
|
||||||
|
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
|
||||||
|
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -216,6 +248,11 @@ const ru: SynapseTranslationMessages = {
|
|||||||
title: "Удалить комнату",
|
title: "Удалить комнату",
|
||||||
content:
|
content:
|
||||||
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
|
||||||
|
fields: {
|
||||||
|
block: "Заблокировать и запретить пользователям присоединяться к комнате",
|
||||||
|
},
|
||||||
|
success: "Комната/ы успешно удалены",
|
||||||
|
failure: "Комната/ы не могут быть удалены.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -409,6 +446,6 @@ const ru: SynapseTranslationMessages = {
|
|||||||
},
|
},
|
||||||
helper: { length: "Длина токена, если токен не задан." },
|
helper: { length: "Длина токена, если токен не задан." },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export default ru;
|
export default ru;
|
||||||
|
@@ -2,7 +2,7 @@ import chineseMessages from "@haxqer/ra-language-chinese";
|
|||||||
|
|
||||||
import { SynapseTranslationMessages } from ".";
|
import { SynapseTranslationMessages } from ".";
|
||||||
|
|
||||||
const zh: SynapseTranslationMessages = {
|
const fixedChineseMessages = {
|
||||||
...chineseMessages,
|
...chineseMessages,
|
||||||
ra: {
|
ra: {
|
||||||
...chineseMessages.ra,
|
...chineseMessages.ra,
|
||||||
@@ -11,7 +11,27 @@ const zh: SynapseTranslationMessages = {
|
|||||||
no_filtered_results: "没有结果",
|
no_filtered_results: "没有结果",
|
||||||
clear_filters: "清除所有过滤器",
|
clear_filters: "清除所有过滤器",
|
||||||
},
|
},
|
||||||
|
action: {
|
||||||
|
...chineseMessages.ra.action,
|
||||||
|
update_application: "Anwendung aktualisieren",
|
||||||
},
|
},
|
||||||
|
page: {
|
||||||
|
...chineseMessages.ra.page,
|
||||||
|
access_denied: "拒绝访问",
|
||||||
|
authentication_error: "认证错误",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
...chineseMessages.ra.message,
|
||||||
|
access_denied:
|
||||||
|
"您没有访问此页面的权限。",
|
||||||
|
authentication_error:
|
||||||
|
"身份验证服务器返回错误,无法验证您的凭据。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const zh: SynapseTranslationMessages = {
|
||||||
|
...fixedChineseMessages,
|
||||||
synapseadmin: {
|
synapseadmin: {
|
||||||
auth: {
|
auth: {
|
||||||
base_url: "服务器 URL",
|
base_url: "服务器 URL",
|
||||||
@@ -21,6 +41,14 @@ const zh: SynapseTranslationMessages = {
|
|||||||
protocol_error: "URL 需要以'http://'或'https://'作为起始",
|
protocol_error: "URL 需要以'http://'或'https://'作为起始",
|
||||||
url_error: "不是一个有效的 Matrix 服务器地址",
|
url_error: "不是一个有效的 Matrix 服务器地址",
|
||||||
sso_sign_in: "使用 SSO 登录",
|
sso_sign_in: "使用 SSO 登录",
|
||||||
|
credentials: "凭证",
|
||||||
|
access_token: "访问令牌",
|
||||||
|
logout_acces_token_dialog: {
|
||||||
|
title: "您正在使用现有的 Matrix 访问令牌。",
|
||||||
|
content: "您想销毁此会话(可能在其他地方使用,例如在 Matrix 客户端中)还是仅从管理面板退出?",
|
||||||
|
confirm: "销毁会话",
|
||||||
|
cancel: "仅从管理面板退出",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
|
||||||
@@ -128,6 +156,7 @@ const zh: SynapseTranslationMessages = {
|
|||||||
deactivated: "被禁用",
|
deactivated: "被禁用",
|
||||||
guests: "显示访客",
|
guests: "显示访客",
|
||||||
show_deactivated: "显示被禁用的账户",
|
show_deactivated: "显示被禁用的账户",
|
||||||
|
show_locked: "显示被锁定的账户",
|
||||||
user_id: "搜索用户",
|
user_id: "搜索用户",
|
||||||
displayname: "显示名字",
|
displayname: "显示名字",
|
||||||
password: "密码",
|
password: "密码",
|
||||||
@@ -142,9 +171,15 @@ const zh: SynapseTranslationMessages = {
|
|||||||
helper: {
|
helper: {
|
||||||
deactivate: "您必须提供一串密码来激活账户。",
|
deactivate: "您必须提供一串密码来激活账户。",
|
||||||
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
erase: "将用户标记为根据 GDPR 的要求抹除了",
|
||||||
|
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
|
||||||
|
erase_admin_error: "不允许删除自己的用户",
|
||||||
|
modify_managed_user_error: "不允许修改系统管理的用户。",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
erase: "抹除用户信息",
|
erase: "抹除用户信息",
|
||||||
|
erase_avatar: "抹掉头像",
|
||||||
|
delete_media: "删除用户上传的所有媒体",
|
||||||
|
redact_events: "重新编辑用户(-s)发送的所有事件",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
@@ -3,16 +3,38 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AppContext } from "./AppContext";
|
import { AppContext, MenuItem } from "./AppContext";
|
||||||
|
import storage from "./storage";
|
||||||
|
|
||||||
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(
|
if (props.asManagedUsers) {
|
||||||
|
storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu: MenuItem[] = [];
|
||||||
|
if (props.menu) {
|
||||||
|
menu = props.menu;
|
||||||
|
}
|
||||||
|
if (props.supportURL) {
|
||||||
|
const migratedSupportURL = {
|
||||||
|
label: "Contact support",
|
||||||
|
icon: "SupportAgent",
|
||||||
|
url: props.supportURL,
|
||||||
|
};
|
||||||
|
console.warn("supportURL config option is deprecated. Please, use the menu option instead. Automatically migrated to the new menu option:", migratedSupportURL);
|
||||||
|
menu.push(migratedSupportURL as MenuItem);
|
||||||
|
}
|
||||||
|
if (menu.length > 0) {
|
||||||
|
storage.setItem("menu", JSON.stringify(menu));
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AppContext.Provider value={props}>
|
<AppContext.Provider value={props}>
|
||||||
<App />
|
<App />
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
);
|
});
|
||||||
|
@@ -8,14 +8,17 @@ import { AppContext } from "../AppContext";
|
|||||||
import englishMessages from "../i18n/en";
|
import englishMessages from "../i18n/en";
|
||||||
|
|
||||||
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
|
||||||
describe("LoginForm", () => {
|
describe("LoginForm", () => {
|
||||||
it("renders with no restriction to homeserver", () => {
|
it("renders with no restriction to homeserver", async () => {
|
||||||
|
await act(async () => {
|
||||||
render(
|
render(
|
||||||
<AdminContext i18nProvider={i18nProvider}>
|
<AdminContext i18nProvider={i18nProvider}>
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
</AdminContext>
|
</AdminContext>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
screen.getByText(englishMessages.synapseadmin.auth.welcome);
|
||||||
screen.getByRole("combobox", { name: "" });
|
screen.getByRole("combobox", { name: "" });
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material";
|
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography } from "@mui/material";
|
||||||
import { styled } from "@mui/material/styles";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormDataConsumer,
|
FormDataConsumer,
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
useLocales,
|
useLocales,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import LoginFormBox from "../components/LoginFormBox";
|
||||||
import { useAppContext } from "../AppContext";
|
import { useAppContext } from "../AppContext";
|
||||||
import {
|
import {
|
||||||
getServerVersion,
|
getServerVersion,
|
||||||
@@ -29,66 +28,18 @@ import {
|
|||||||
} from "../synapse/synapse";
|
} from "../synapse/synapse";
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
|
||||||
const FormBox = styled(Box)(({ theme }) => ({
|
export type LoginMethod = "credentials" | "accessToken";
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
minHeight: "calc(100vh - 1rem)",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
background: "url(./images/floating-cogs.svg)",
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
|
|
||||||
[`& .card`]: {
|
|
||||||
width: "30rem",
|
|
||||||
marginTop: "6rem",
|
|
||||||
marginBottom: "6rem",
|
|
||||||
},
|
|
||||||
[`& .avatar`]: {
|
|
||||||
margin: "1rem",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
[`& .icon`]: {
|
|
||||||
backgroundColor: theme.palette.grey[500],
|
|
||||||
},
|
|
||||||
[`& .hint`]: {
|
|
||||||
marginTop: "1em",
|
|
||||||
marginBottom: "1em",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: theme.palette.grey[600],
|
|
||||||
},
|
|
||||||
[`& .form`]: {
|
|
||||||
padding: "0 1rem 1rem 1rem",
|
|
||||||
},
|
|
||||||
[`& .select`]: {
|
|
||||||
marginBottom: "2rem",
|
|
||||||
},
|
|
||||||
[`& .actions`]: {
|
|
||||||
padding: "0 1rem 1rem 1rem",
|
|
||||||
},
|
|
||||||
[`& .serverVersion`]: {
|
|
||||||
color: theme.palette.grey[500],
|
|
||||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
marginLeft: "0.5rem",
|
|
||||||
},
|
|
||||||
[`& .matrixVersions`]: {
|
|
||||||
color: theme.palette.grey[500],
|
|
||||||
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
marginLeft: "0.5rem",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const { restrictBaseUrl } = useAppContext();
|
const { restrictBaseUrl } = useAppContext();
|
||||||
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
|
const allowSingleBaseUrl = typeof restrictBaseUrl === "string";
|
||||||
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
|
const allowMultipleBaseUrls =
|
||||||
|
Array.isArray(restrictBaseUrl) &&
|
||||||
|
restrictBaseUrl.length > 0 &&
|
||||||
|
restrictBaseUrl[0] !== "" &&
|
||||||
|
restrictBaseUrl[0] !== null;
|
||||||
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
|
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [supportPassAuth, setSupportPassAuth] = useState(true);
|
const [supportPassAuth, setSupportPassAuth] = useState(true);
|
||||||
@@ -98,8 +49,13 @@ const LoginPage = () => {
|
|||||||
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url");
|
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url");
|
||||||
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
|
||||||
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
|
||||||
|
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loginToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (loginToken) {
|
|
||||||
const ssoToken = loginToken[1];
|
const ssoToken = loginToken[1];
|
||||||
console.log("SSO token is", ssoToken);
|
console.log("SSO token is", ssoToken);
|
||||||
// Prevent further requests
|
// Prevent further requests
|
||||||
@@ -127,7 +83,7 @@ const LoginPage = () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, [loginToken]);
|
||||||
|
|
||||||
const validateBaseUrl = value => {
|
const validateBaseUrl = value => {
|
||||||
if (!value.match(/^(http|https):\/\//)) {
|
if (!value.match(/^(http|https):\/\//)) {
|
||||||
@@ -168,7 +124,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 +138,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]);
|
||||||
}
|
}
|
||||||
@@ -207,6 +168,18 @@ const LoginPage = () => {
|
|||||||
}, [formData.base_url, form]);
|
}, [formData.base_url, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs
|
||||||
|
value={loginMethod}
|
||||||
|
onChange={(_, newValue) => setLoginMethod(newValue as LoginMethod)}
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Tab label={translate("synapseadmin.auth.credentials")} value="credentials" />
|
||||||
|
<Tab label={translate("synapseadmin.auth.access_token")} value="accessToken" />
|
||||||
|
</Tabs>
|
||||||
|
{loginMethod === "credentials" ? (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -231,6 +204,18 @@ const LoginPage = () => {
|
|||||||
validate={required()}
|
validate={required()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<TextInput
|
||||||
|
source="accessToken"
|
||||||
|
label="synapseadmin.auth.access_token"
|
||||||
|
disabled={loading}
|
||||||
|
resettable
|
||||||
|
validate={required()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box>
|
<Box>
|
||||||
<TextInput
|
<TextInput
|
||||||
source="base_url"
|
source="base_url"
|
||||||
@@ -258,7 +243,7 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
|
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched">
|
||||||
<FormBox>
|
<LoginFormBox>
|
||||||
<Card className="card">
|
<Card className="card">
|
||||||
<Box className="avatar">
|
<Box className="avatar">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -272,9 +257,9 @@ const LoginPage = () => {
|
|||||||
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
|
||||||
<Box className="form">
|
<Box className="form">
|
||||||
<Select
|
<Select
|
||||||
|
fullWidth
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={e => setLocale(e.target.value)}
|
onChange={e => setLocale(e.target.value)}
|
||||||
fullWidth
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="select"
|
className="select"
|
||||||
>
|
>
|
||||||
@@ -285,7 +270,7 @@ const LoginPage = () => {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
|
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer>
|
||||||
<CardActions className="actions">
|
{loginMethod === "credentials" && <CardActions className="actions">
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -304,10 +289,21 @@ const LoginPage = () => {
|
|||||||
>
|
>
|
||||||
{translate("synapseadmin.auth.sso_sign_in")}
|
{translate("synapseadmin.auth.sso_sign_in")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardActions>
|
</CardActions>}
|
||||||
|
{loginMethod === "accessToken" && <CardActions className="actions">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
disabled={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{translate("ra.auth.sign_in")}
|
||||||
|
</Button>
|
||||||
|
</CardActions>}
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</FormBox>
|
</LoginFormBox>
|
||||||
<Notification />
|
<Notification />
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
@@ -1,11 +1,10 @@
|
|||||||
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 ErrorIcon from '@mui/icons-material/Error';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
Tab,
|
Tab,
|
||||||
TabbedShowLayout,
|
TabbedShowLayout,
|
||||||
TextField,
|
TextField,
|
||||||
|
FunctionField,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
useRecordContext,
|
useRecordContext,
|
||||||
useDelete,
|
useDelete,
|
||||||
@@ -33,7 +33,7 @@ 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]} />;
|
||||||
|
|
||||||
@@ -100,17 +100,22 @@ const RetryDateField = (props: DateFieldProps) => {
|
|||||||
return <DateField {...props} />;
|
return <DateField {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const destinationFieldRender = (record: RaRecord) => {
|
||||||
|
if (record.retry_last_ts > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ErrorIcon fontSize="inherit" color="error" sx={{verticalAlign: "middle"}}/>
|
||||||
|
|
||||||
|
{record.destination}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <> {record.destination} </>;
|
||||||
|
}
|
||||||
|
|
||||||
export const DestinationList = (props: ListProps) => {
|
export const DestinationList = (props: ListProps) => {
|
||||||
const { palette: { error, mode }, } = useTheme();
|
const record = useRecordContext(props);
|
||||||
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}
|
||||||
@@ -118,8 +123,8 @@ export const DestinationList = (props: ListProps) => {
|
|||||||
pagination={<DestinationPagination />}
|
pagination={<DestinationPagination />}
|
||||||
sort={{ field: "destination", order: "ASC" }}
|
sort={{ field: "destination", order: "ASC" }}
|
||||||
>
|
>
|
||||||
<Datagrid rowSx={destinationRowSx} rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
<Datagrid rowClick={id => `${id}/show/rooms`} bulkActionButtons={false}>
|
||||||
<TextField source="destination" />
|
<FunctionField source="destination" render={destinationFieldRender} />
|
||||||
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
|
<DateField source="failure_ts" showTime options={DATE_FORMAT} />
|
||||||
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
|
<RetryDateField source="retry_last_ts" showTime options={DATE_FORMAT} />
|
||||||
<TextField source="retry_interval" />
|
<TextField source="retry_interval" />
|
||||||
|
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
import { MXCField } from "../components/media";
|
import { ReportMediaContent } from "../components/media";
|
||||||
|
|
||||||
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const ReportShow = (props: ShowProps) => {
|
|||||||
<TextField source="event_json.content.msgtype" />
|
<TextField source="event_json.content.msgtype" />
|
||||||
<TextField source="event_json.content.body" />
|
<TextField source="event_json.content.body" />
|
||||||
<TextField source="event_json.content.info.mimetype" />
|
<TextField source="event_json.content.info.mimetype" />
|
||||||
<MXCField source="event_json.content.url" />
|
<ReportMediaContent source="event_json.content.url" />
|
||||||
<TextField source="event_json.content.format" />
|
<TextField source="event_json.content.format" />
|
||||||
<TextField source="event_json.content.formatted_body" />
|
<TextField source="event_json.content.formatted_body" />
|
||||||
<TextField source="event_json.content.algorithm" />
|
<TextField source="event_json.content.algorithm" />
|
||||||
|
@@ -26,9 +26,9 @@ import {
|
|||||||
useUnselectAll,
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import AvatarField from "../components/AvatarField";
|
import AvatarField from "../components/AvatarField";
|
||||||
|
|
||||||
|
|
||||||
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
|
||||||
|
|
||||||
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
|
||||||
@@ -101,7 +101,7 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
|
|||||||
const [create, { isLoading }] = useCreate();
|
const [create, { isLoading }] = useCreate();
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
@@ -144,7 +144,6 @@ export const RoomDirectoryList = () => (
|
|||||||
>
|
>
|
||||||
<AvatarField
|
<AvatarField
|
||||||
source="avatar_src"
|
source="avatar_src"
|
||||||
sortable={false}
|
|
||||||
sx={{ height: "40px", width: "40px" }}
|
sx={{ height: "40px", width: "40px" }}
|
||||||
label="resources.rooms.fields.avatar"
|
label="resources.rooms.fields.avatar"
|
||||||
/>
|
/>
|
||||||
|
@@ -36,6 +36,7 @@ import {
|
|||||||
TopToolbar,
|
TopToolbar,
|
||||||
useRecordContext,
|
useRecordContext,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
|
useListContext,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,8 @@ 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";
|
||||||
|
import AvatarField from "../components/AvatarField";
|
||||||
|
|
||||||
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
@@ -65,13 +68,16 @@ const RoomTitle = () => {
|
|||||||
|
|
||||||
const RoomShowActions = () => {
|
const RoomShowActions = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
|
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
|
||||||
// FIXME: refresh after (un)publish
|
// FIXME: refresh after (un)publish
|
||||||
return (
|
return (
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
@@ -85,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
|
|||||||
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
|
||||||
<TabbedShowLayout>
|
<TabbedShowLayout>
|
||||||
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
||||||
|
<AvatarField
|
||||||
|
source="avatar"
|
||||||
|
sx={{ height: "120px", width: "120px" }}
|
||||||
|
label="resources.rooms.fields.avatar"
|
||||||
|
/>
|
||||||
<TextField source="room_id" />
|
<TextField source="room_id" />
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
<TextField source="topic" />
|
<TextField source="topic" />
|
||||||
@@ -207,17 +218,20 @@ export const RoomShow = (props: ShowProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoomBulkActionButtons = () => (
|
const RoomBulkActionButtons = () => {
|
||||||
|
const record = useListContext();
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<RoomDirectoryBulkPublishButton />
|
<RoomDirectoryBulkPublishButton />
|
||||||
<RoomDirectoryBulkUnpublishButton />
|
<RoomDirectoryBulkUnpublishButton />
|
||||||
<BulkDeleteButton
|
<DeleteRoomButton
|
||||||
|
selectedIds={record.selectedIds}
|
||||||
confirmTitle="resources.rooms.action.erase.title"
|
confirmTitle="resources.rooms.action.erase.title"
|
||||||
confirmContent="resources.rooms.action.erase.content"
|
confirmContent="resources.rooms.action.erase.content"
|
||||||
mutationMode="pessimistic"
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import EqualizerIcon from "@mui/icons-material/Equalizer";
|
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
ExportButton,
|
ExportButton,
|
||||||
@@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (
|
|||||||
|
|
||||||
const resource: ResourceProps = {
|
const resource: ResourceProps = {
|
||||||
name: "user_media_statistics",
|
name: "user_media_statistics",
|
||||||
icon: EqualizerIcon,
|
icon: PermMediaIcon,
|
||||||
list: UserMediaStatsList,
|
list: UserMediaStatsList,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
@@ -34,7 +36,6 @@ import {
|
|||||||
ResourceProps,
|
ResourceProps,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
SelectInput,
|
SelectInput,
|
||||||
BulkDeleteButton,
|
|
||||||
DeleteButton,
|
DeleteButton,
|
||||||
maxLength,
|
maxLength,
|
||||||
regex,
|
regex,
|
||||||
@@ -42,16 +43,26 @@ import {
|
|||||||
useRecordContext,
|
useRecordContext,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
SaveButton,
|
||||||
CreateButton,
|
CreateButton,
|
||||||
ExportButton,
|
ExportButton,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
|
Toolbar,
|
||||||
NumberField,
|
NumberField,
|
||||||
useListContext,
|
useListContext,
|
||||||
|
useNotify,
|
||||||
Identifier,
|
Identifier,
|
||||||
|
ToolbarClasses,
|
||||||
|
RaRecord,
|
||||||
|
ImageInput,
|
||||||
|
ImageField,
|
||||||
|
FunctionField,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import AvatarField from "../components/AvatarField";
|
import AvatarField from "../components/AvatarField";
|
||||||
|
import DeleteUserButton from "../components/DeleteUserButton";
|
||||||
|
import { isASManaged } from "../components/mxid";
|
||||||
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
import { DeviceRemoveButton } from "../components/devices";
|
import { DeviceRemoveButton } from "../components/devices";
|
||||||
@@ -80,11 +91,6 @@ const UserListActions = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
UserListActions.defaultProps = {
|
|
||||||
selectedIds: [],
|
|
||||||
onUnselectItems: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
|
||||||
|
|
||||||
const userFilters = [
|
const userFilters = [
|
||||||
@@ -94,16 +100,52 @@ 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; asManagedUserIsSelected: boolean }> = props => {
|
||||||
|
const ownUserIsSelected = props.ownUserIsSelected;
|
||||||
|
const asManagedUserIsSelected = props.asManagedUserIsSelected;
|
||||||
|
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();
|
||||||
|
} else if (asManagedUserIsSelected) {
|
||||||
|
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserBulkActionButtons = () => {
|
||||||
|
const record = useListContext();
|
||||||
|
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
|
||||||
|
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
||||||
|
const selectedIds = record.selectedIds;
|
||||||
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
|
const notify = useNotify();
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||||
|
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
|
||||||
|
}, [selectedIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<ServerNoticeBulkButton />
|
<ServerNoticeBulkButton />
|
||||||
<BulkDeleteButton
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
label="resources.users.action.erase"
|
<DeleteUserButton
|
||||||
|
selectedIds={selectedIds}
|
||||||
confirmTitle="resources.users.helper.erase"
|
confirmTitle="resources.users.helper.erase"
|
||||||
mutationMode="pessimistic"
|
confirmContent="resources.users.helper.erase_text"
|
||||||
/>
|
/>
|
||||||
|
</UserPreventSelfDelete>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const UserList = (props: ListProps) => (
|
export const UserList = (props: ListProps) => (
|
||||||
<List
|
<List
|
||||||
@@ -142,17 +184,26 @@ 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;
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
|
if (record && record.id) {
|
||||||
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopToolbar>
|
<TopToolbar>
|
||||||
{!record?.deactivated && <ServerNoticeButton />}
|
{!record?.deactivated && <ServerNoticeButton />}
|
||||||
<DeleteButton
|
{record && record.id && (
|
||||||
label="resources.users.action.erase"
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
confirmTitle={translate("resources.users.helper.erase", {
|
<DeleteUserButton
|
||||||
smart_count: 1,
|
selectedIds={[record?.id]}
|
||||||
})}
|
confirmTitle="resources.users.helper.erase"
|
||||||
mutationMode="pessimistic"
|
confirmContent="resources.users.helper.erase_text"
|
||||||
/>
|
/>
|
||||||
|
</UserPreventSelfDelete>
|
||||||
|
)}
|
||||||
</TopToolbar>
|
</TopToolbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -160,9 +211,7 @@ const UserEditActions = () => {
|
|||||||
export const UserCreate = (props: CreateProps) => (
|
export const UserCreate = (props: CreateProps) => (
|
||||||
<Create
|
<Create
|
||||||
{...props}
|
{...props}
|
||||||
redirect={(resource: string | undefined, id: Identifier | undefined) => {
|
redirect={(resource: string | undefined, id: Identifier | undefined) => `${resource}/${id}`}
|
||||||
return `${resource}/${id}`;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SimpleForm>
|
<SimpleForm>
|
||||||
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
<TextInput source="id" autoComplete="off" validate={validateUser} />
|
||||||
@@ -188,31 +237,106 @@ export const UserCreate = (props: CreateProps) => (
|
|||||||
|
|
||||||
const UserTitle = () => {
|
const UserTitle = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||||
|
if (isASManaged(record?.id)) {
|
||||||
|
username += " 🤖";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{translate("resources.users.name", {
|
{translate("resources.users.name", {
|
||||||
smart_count: 1,
|
smart_count: 1,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
{record ? `"${record.displayname}"` : ""}
|
{username}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UserEditToolbar = () => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
|
let ownUserIsSelected = false;
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
|
if (record && record.id) {
|
||||||
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={ToolbarClasses.defaultToolbar}>
|
||||||
|
<Toolbar sx={{ justifyContent: "space-between" }}>
|
||||||
|
<SaveButton />
|
||||||
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
|
<DeleteButton />
|
||||||
|
</UserPreventSelfDelete>
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserBooleanInput = props => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
|
let ownUserIsSelected = false;
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
|
if (record) {
|
||||||
|
ownUserIsSelected = record.id === ownUserId;
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
|
||||||
|
<BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
|
||||||
|
</UserPreventSelfDelete>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserPasswordInput = props => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
let asManagedUserIsSelected = false;
|
||||||
|
if (record) {
|
||||||
|
asManagedUserIsSelected = isASManaged(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error" disabled={asManagedUserIsSelected} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const UserEdit = (props: EditProps) => {
|
export const UserEdit = (props: EditProps) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
|
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
|
||||||
<TabbedForm>
|
<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" sx={{ height: "120px", width: "120px" }} />
|
||||||
<TextInput source="id" disabled />
|
<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" sx={{ '& img': {
|
||||||
|
width: "120px !important",
|
||||||
|
height: "120px !important",
|
||||||
|
objectFit: "cover !important",
|
||||||
|
borderRadius: '50% !important',
|
||||||
|
}}} />
|
||||||
|
</ImageInput>
|
||||||
|
<TextInput source="id" readOnly />
|
||||||
<TextInput source="displayname" />
|
<TextInput source="displayname" />
|
||||||
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
|
||||||
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
|
||||||
<BooleanInput source="admin" />
|
<BooleanInput source="admin" />
|
||||||
<BooleanInput source="locked" />
|
<UserBooleanInput source="locked" />
|
||||||
<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" />
|
||||||
@@ -238,7 +362,7 @@ export const UserEdit = (props: EditProps) => {
|
|||||||
|
|
||||||
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
|
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
|
||||||
<ReferenceManyField reference="devices" target="user_id" label={false}>
|
<ReferenceManyField reference="devices" target="user_id" label={false}>
|
||||||
<Datagrid style={{ width: "100%" }}>
|
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
|
||||||
<TextField source="device_id" sortable={false} />
|
<TextField source="device_id" sortable={false} />
|
||||||
<TextField source="display_name" sortable={false} />
|
<TextField source="display_name" sortable={false} />
|
||||||
<TextField source="last_seen_ip" sortable={false} />
|
<TextField source="last_seen_ip" sortable={false} />
|
||||||
@@ -278,8 +402,8 @@ export const UserEdit = (props: EditProps) => {
|
|||||||
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
<DateField source="created_ts" showTime options={DATE_FORMAT} />
|
||||||
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
|
||||||
<NumberField source="media_length" />
|
<NumberField source="media_length" />
|
||||||
<TextField source="media_type" />
|
<TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
|
||||||
<TextField source="upload_name" />
|
<FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
|
||||||
<TextField source="quarantined_by" />
|
<TextField source="quarantined_by" />
|
||||||
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
|
||||||
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
|
||||||
|
@@ -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();
|
||||||
|
|
||||||
@@ -22,25 +23,15 @@ describe("authProvider", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ret: undefined = await authProvider.login({
|
const ret = await authProvider.login({
|
||||||
base_url: "http://example.com",
|
base_url: "http://example.com",
|
||||||
username: "@user:example.com",
|
username: "@user:example.com",
|
||||||
password: "secret",
|
password: "secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ret).toBe(undefined);
|
expect(ret).toEqual({redirectTo: "/"});
|
||||||
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
|
expect(fetch).toHaveBeenCalledWith("http://example.com/_matrix/client/v3/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",
|
||||||
@@ -64,13 +55,13 @@ describe("authProvider", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ret: undefined = await authProvider.login({
|
const ret = await authProvider.login({
|
||||||
base_url: "https://example.com/",
|
base_url: "https://example.com/",
|
||||||
loginToken: "login_token",
|
loginToken: "login_token",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ret).toBe(undefined);
|
expect(ret).toEqual({redirectTo: "/"});
|
||||||
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
|
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/v3/login", {
|
||||||
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
|
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -92,7 +83,7 @@ describe("authProvider", () => {
|
|||||||
|
|
||||||
await authProvider.logout(null);
|
await authProvider.logout(null);
|
||||||
|
|
||||||
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
|
expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: "Bearer foo",
|
Authorization: "Bearer foo",
|
||||||
@@ -110,11 +101,11 @@ describe("authProvider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reject if error.status is 401", async () => {
|
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 () => {
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +123,9 @@ describe("authProvider", () => {
|
|||||||
|
|
||||||
describe("getPermissions", () => {
|
describe("getPermissions", () => {
|
||||||
it("should do nothing", async () => {
|
it("should do nothing", async () => {
|
||||||
|
if (authProvider.getPermissions) {
|
||||||
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
|
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import { AuthProvider, Options, fetchUtils } from "react-admin";
|
import { AuthProvider, HttpError, Options, fetchUtils } from "react-admin";
|
||||||
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { MatrixError, displayError } from "../components/error";
|
||||||
|
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
const authProvider: AuthProvider = {
|
||||||
// called when the user attempts to log in
|
// called when the user attempts to log in
|
||||||
@@ -9,14 +11,16 @@ const authProvider: AuthProvider = {
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
loginToken,
|
loginToken,
|
||||||
|
accessToken,
|
||||||
}: {
|
}: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
loginToken: string;
|
loginToken: string;
|
||||||
|
accessToken: string;
|
||||||
}) => {
|
}) => {
|
||||||
console.log("login ");
|
console.log("login ");
|
||||||
const options: Options = {
|
let options: Options = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
Object.assign(
|
Object.assign(
|
||||||
@@ -31,12 +35,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,23 +48,96 @@ 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";
|
let login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/v3/login");
|
||||||
|
|
||||||
const { json } = await fetchUtils.fetchJson(login_api_url, options);
|
let response;
|
||||||
storage.setItem("home_server", json.home_server);
|
|
||||||
|
try {
|
||||||
|
if (accessToken) {
|
||||||
|
// this a login with an already obtained access token, let's just validate it
|
||||||
|
options = {
|
||||||
|
headers: new Headers({
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetchUtils.fetchJson(login_api_url, options);
|
||||||
|
const json = response.json;
|
||||||
|
storage.setItem("home_server", accessToken ? base_url : 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", accessToken ? accessToken : json.access_token);
|
||||||
storage.setItem("device_id", json.device_id);
|
storage.setItem("device_id", json.device_id);
|
||||||
|
storage.setItem("login_type", accessToken ? "accessToken" : "credentials");
|
||||||
|
|
||||||
|
return Promise.resolve({redirectTo: "/"});
|
||||||
|
} catch(err) {
|
||||||
|
const error = err as HttpError;
|
||||||
|
const errorStatus = error.status;
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getIdentity: async () => {
|
||||||
|
const access_token = storage.getItem("access_token");
|
||||||
|
const user_id = storage.getItem("user_id");
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
|
||||||
|
if (typeof access_token !== "string" || typeof user_id !== "string" || typeof base_url !== "string") {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Options = {
|
||||||
|
headers: new Headers({
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const whoami_api_url = base_url + `/_matrix/client/v3/profile/${user_id}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let avatar_url = "";
|
||||||
|
const response = await fetchUtils.fetchJson(whoami_api_url, options);
|
||||||
|
if (response.json.avatar_url) {
|
||||||
|
const mediaresp = await fetchAuthenticatedMedia(response.json.avatar_url, "thumbnail");
|
||||||
|
const blob = await mediaresp.blob();
|
||||||
|
avatar_url = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
id: user_id,
|
||||||
|
fullName: response.json.displayname,
|
||||||
|
avatar: avatar_url,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error getting identity", err);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// called when the user clicks on the logout button
|
// called when the user clicks on the logout button
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
console.log("logout");
|
console.log("logout");
|
||||||
|
|
||||||
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/r0/logout";
|
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/v3/logout";
|
||||||
const access_token = storage.getItem("access_token");
|
const access_token = storage.getItem("access_token");
|
||||||
|
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
@@ -73,15 +149,23 @@ const authProvider: AuthProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (typeof access_token === "string") {
|
if (typeof access_token === "string") {
|
||||||
|
try {
|
||||||
await fetchUtils.fetchJson(logout_api_url, options);
|
await fetchUtils.fetchJson(logout_api_url, options);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error logging out", err);
|
||||||
|
} finally {
|
||||||
storage.removeItem("access_token");
|
storage.removeItem("access_token");
|
||||||
|
storage.removeItem("login_type");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 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();
|
||||||
},
|
},
|
||||||
|
@@ -18,7 +18,7 @@ describe("dataProvider", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
name: "user_id1",
|
name: "@user_id1:provider",
|
||||||
password_hash: "password_hash1",
|
password_hash: "password_hash1",
|
||||||
is_guest: 0,
|
is_guest: 0,
|
||||||
admin: 0,
|
admin: 0,
|
||||||
@@ -27,7 +27,7 @@ describe("dataProvider", () => {
|
|||||||
displayname: "User One",
|
displayname: "User One",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "user_id2",
|
name: "@user_id2:provider",
|
||||||
password_hash: "password_hash2",
|
password_hash: "password_hash2",
|
||||||
is_guest: 0,
|
is_guest: 0,
|
||||||
admin: 1,
|
admin: 1,
|
||||||
@@ -47,7 +47,7 @@ describe("dataProvider", () => {
|
|||||||
filter: { author_id: 12 },
|
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(users.total).toEqual(200);
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,7 @@ describe("dataProvider", () => {
|
|||||||
it("fetches one user", async () => {
|
it("fetches one user", async () => {
|
||||||
fetchMock.mockResponseOnce(
|
fetchMock.mockResponseOnce(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: "user_id1",
|
name: "@user_id1:provider",
|
||||||
password: "user_password",
|
password: "user_password",
|
||||||
displayname: "User",
|
displayname: "User",
|
||||||
threepids: [
|
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(user.data.displayname).toEqual("User");
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
@@ -1,40 +1,57 @@
|
|||||||
import { stringify } from "query-string";
|
|
||||||
import {
|
import {
|
||||||
DataProvider,
|
DataProvider,
|
||||||
DeleteParams,
|
DeleteParams,
|
||||||
|
DeleteManyParams,
|
||||||
|
HttpError,
|
||||||
Identifier,
|
Identifier,
|
||||||
Options,
|
Options,
|
||||||
PaginationPayload,
|
PaginationPayload,
|
||||||
RaRecord,
|
RaRecord,
|
||||||
SortPayload,
|
SortPayload,
|
||||||
fetchUtils
|
UpdateParams,
|
||||||
|
fetchUtils,
|
||||||
|
withLifecycleCallbacks,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
import { returnMXID } from "./synapse";
|
||||||
|
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) {
|
||||||
options.user = {
|
options.user = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
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 filterUndefined = (obj: Record<string, any>) => {
|
||||||
const homeserver = storage.getItem("base_url");
|
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
|
||||||
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
|
||||||
const ret = re.exec(mxcUrl);
|
|
||||||
console.log("mxcClient " + ret);
|
|
||||||
if (ret == null) return null;
|
|
||||||
const serverName = ret[1];
|
|
||||||
const mediaId = ret[2];
|
|
||||||
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
endpoint: string;
|
||||||
|
method?: string;
|
||||||
|
body?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -221,17 +238,28 @@ export interface DeleteMediaResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadMediaParams {
|
||||||
|
file: File;
|
||||||
|
filename: string;
|
||||||
|
content_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadMediaResult {
|
||||||
|
content_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SynapseDataProvider extends DataProvider {
|
export interface SynapseDataProvider extends DataProvider {
|
||||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||||
|
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceMap = {
|
const resourceMap = {
|
||||||
users: {
|
users: {
|
||||||
path: "/_synapse/admin/v2/users",
|
path: "/_synapse/admin/v2/users",
|
||||||
map: (u: User) => ({
|
map: async (u: User) => ({
|
||||||
...u,
|
...u,
|
||||||
id: u.name,
|
id: returnMXID(u.name),
|
||||||
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
|
avatar_src: u.avatar_url ? u.avatar_url : undefined,
|
||||||
is_guest: !!u.is_guest,
|
is_guest: !!u.is_guest,
|
||||||
admin: !!u.admin,
|
admin: !!u.admin,
|
||||||
deactivated: !!u.deactivated,
|
deactivated: !!u.deactivated,
|
||||||
@@ -241,12 +269,12 @@ const resourceMap = {
|
|||||||
data: "users",
|
data: "users",
|
||||||
total: json => json.total,
|
total: json => json.total,
|
||||||
create: (data: RaRecord) => ({
|
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,
|
body: data,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
}),
|
}),
|
||||||
delete: (params: DeleteParams) => ({
|
delete: (params: DeleteParams) => ({
|
||||||
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
|
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`,
|
||||||
body: { erase: true },
|
body: { erase: true },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
@@ -266,7 +294,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: {
|
||||||
@@ -345,7 +373,7 @@ const resourceMap = {
|
|||||||
id: um.media_id,
|
id: um.media_id,
|
||||||
}),
|
}),
|
||||||
reference: (id: Identifier) => ({
|
reference: (id: Identifier) => ({
|
||||||
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
|
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`,
|
||||||
}),
|
}),
|
||||||
data: "media",
|
data: "media",
|
||||||
total: json => json.total,
|
total: json => json.total,
|
||||||
@@ -380,7 +408,7 @@ const resourceMap = {
|
|||||||
create: (data: RaServerNotice) => ({
|
create: (data: RaServerNotice) => ({
|
||||||
endpoint: "/_synapse/admin/v1/send_server_notice",
|
endpoint: "/_synapse/admin/v1/send_server_notice",
|
||||||
body: {
|
body: {
|
||||||
user_id: data.id,
|
user_id: returnMXID(data.id),
|
||||||
content: {
|
content: {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: data.body,
|
body: data.body,
|
||||||
@@ -393,7 +421,7 @@ const resourceMap = {
|
|||||||
path: "/_synapse/admin/v1/statistics/users/media",
|
path: "/_synapse/admin/v1/statistics/users/media",
|
||||||
map: (usms: UserMediaStatistic) => ({
|
map: (usms: UserMediaStatistic) => ({
|
||||||
...usms,
|
...usms,
|
||||||
id: usms.user_id,
|
id: returnMXID(usms.user_id),
|
||||||
}),
|
}),
|
||||||
data: "users",
|
data: "users",
|
||||||
total: json => json.total,
|
total: json => json.total,
|
||||||
@@ -419,7 +447,7 @@ const resourceMap = {
|
|||||||
id: rd.room_id,
|
id: rd.room_id,
|
||||||
public: !!rd.public,
|
public: !!rd.public,
|
||||||
guest_access: !!rd.guest_access,
|
guest_access: !!rd.guest_access,
|
||||||
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
|
avatar_src: rd.avatar_url ? rd.avatar_url : undefined,
|
||||||
}),
|
}),
|
||||||
data: "chunk",
|
data: "chunk",
|
||||||
total: json => json.total_room_count_estimate,
|
total: json => json.total_room_count_estimate,
|
||||||
@@ -495,7 +523,7 @@ function getSearchOrder(order: "ASC" | "DESC") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataProvider: SynapseDataProvider = {
|
const baseDataProvider: SynapseDataProvider = {
|
||||||
getList: async (resource, params) => {
|
getList: async (resource, params) => {
|
||||||
console.log("getList " + resource);
|
console.log("getList " + resource);
|
||||||
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
|
||||||
@@ -522,11 +550,13 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
const endpoint_url = homeserver + res.path;
|
const endpoint_url = homeserver + res.path;
|
||||||
const url = `${endpoint_url}?${stringify(query)}`;
|
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||||
|
|
||||||
const { json } = await jsonClient(url);
|
const { json } = await jsonClient(url);
|
||||||
|
const formattedData = await json[res.data].map(res.map);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: json[res.data].map(res.map),
|
data: formattedData,
|
||||||
total: res.total(json, from, perPage),
|
total: res.total(json, from, perPage),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -576,7 +606,7 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
const ref = res.reference(params.id);
|
const ref = res.reference(params.id);
|
||||||
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
|
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
|
||||||
|
|
||||||
const { json } = await jsonClient(endpoint_url);
|
const { json } = await jsonClient(endpoint_url);
|
||||||
return {
|
return {
|
||||||
@@ -682,7 +712,7 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteMany: async (resource, params) => {
|
deleteMany: async (resource, params) => {
|
||||||
console.log("deleteMany " + resource);
|
console.log("deleteMany " + resource, "params", params);
|
||||||
const homeserver = storage.getItem("base_url");
|
const homeserver = storage.getItem("base_url");
|
||||||
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
|
||||||
|
|
||||||
@@ -699,6 +729,7 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: responses.map(({ json }) => json),
|
data: responses.map(({ json }) => json),
|
||||||
};
|
};
|
||||||
@@ -737,6 +768,79 @@ const dataProvider: SynapseDataProvider = {
|
|||||||
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
const { json } = await jsonClient(endpoint_url, { method: "POST" });
|
||||||
return json as DeleteMediaResult;
|
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;
|
||||||
|
},
|
||||||
|
beforeDelete: async (params: DeleteParams<any>, dataProvider: DataProvider) => {
|
||||||
|
if (params.meta?.deleteMedia) {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(params.id))}/media`;
|
||||||
|
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.meta?.redactEvents) {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(params.id))}/redact`;
|
||||||
|
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
beforeDeleteMany: async (params: DeleteManyParams<any>, dataProvider: DataProvider) => {
|
||||||
|
await Promise.all(
|
||||||
|
params.ids.map(async id => {
|
||||||
|
if (params.meta?.deleteMedia) {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`;
|
||||||
|
await jsonClient(endpoint_url, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.meta?.redactEvents) {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(id))}/redact`;
|
||||||
|
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export default dataProvider;
|
export default dataProvider;
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { fetchUtils } from "react-admin";
|
import { Identifier, fetchUtils } from "react-admin";
|
||||||
|
|
||||||
import storage from "../storage";
|
import storage from "../storage";
|
||||||
|
|
||||||
@@ -54,11 +54,6 @@ export const getSupportedLoginFlows = async baseUrl => {
|
|||||||
return response.json.flows;
|
return response.json.flows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMediaUrl = media_id => {
|
|
||||||
const baseUrl = storage.getItem("base_url");
|
|
||||||
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a random MXID for current homeserver
|
* Generate a random MXID for current homeserver
|
||||||
* @returns full MXID as string
|
* @returns full MXID as string
|
||||||
@@ -72,6 +67,26 @@ export function generateRandomMxId(): string {
|
|||||||
return `@${localpart}:${homeserver}`;
|
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
|
* Generate a random user password
|
||||||
* @returns a new random password as string
|
* @returns a new random password as string
|
||||||
|
43
src/utils/fetchMedia.ts
Normal file
43
src/utils/fetchMedia.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import storage from "../storage";
|
||||||
|
|
||||||
|
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
|
||||||
|
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||||
|
const ret = re.exec(mxcUrl);
|
||||||
|
console.log("mxcClient " + ret);
|
||||||
|
if (ret == null) {
|
||||||
|
throw new Error("Invalid mxcUrl");
|
||||||
|
}
|
||||||
|
const serverName = ret[1];
|
||||||
|
const mediaId = ret[2];
|
||||||
|
return { serverName, mediaId };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaType = "thumbnail" | "original";
|
||||||
|
|
||||||
|
export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise<Response> => {
|
||||||
|
const homeserver = storage.getItem("base_url");
|
||||||
|
const accessToken = storage.getItem("access_token");
|
||||||
|
|
||||||
|
const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
|
||||||
|
if (!serverName || !mediaId) {
|
||||||
|
throw new Error("Invalid mxcUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = "";
|
||||||
|
if (type === "thumbnail") {
|
||||||
|
// ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
|
||||||
|
url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
|
||||||
|
} else if (type === "original") {
|
||||||
|
url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid authenticated media type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${url}`, {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
191
testdata/synapse/homeserver.yaml
vendored
Normal file
191
testdata/synapse/homeserver.yaml
vendored
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
account_threepid_delegates:
|
||||||
|
msisdn: ''
|
||||||
|
alias_creation_rules:
|
||||||
|
- action: allow
|
||||||
|
alias: '*'
|
||||||
|
room_id: '*'
|
||||||
|
user_id: '*'
|
||||||
|
allow_guest_access: false
|
||||||
|
allow_public_rooms_over_federation: true
|
||||||
|
allow_public_rooms_without_auth: true
|
||||||
|
app_service_config_files: []
|
||||||
|
autocreate_auto_join_rooms: true
|
||||||
|
background_updates: null
|
||||||
|
caches:
|
||||||
|
global_factor: 0.5
|
||||||
|
per_cache_factors: null
|
||||||
|
cas_config: null
|
||||||
|
database:
|
||||||
|
args:
|
||||||
|
cp_max: 10
|
||||||
|
cp_min: 5
|
||||||
|
database: synapse
|
||||||
|
host: postgres
|
||||||
|
password: synapse
|
||||||
|
port: 5432
|
||||||
|
user: synapse
|
||||||
|
name: psycopg2
|
||||||
|
txn_limit: 0
|
||||||
|
default_room_version: '10'
|
||||||
|
disable_msisdn_registration: true
|
||||||
|
email:
|
||||||
|
enable_media_repo: true
|
||||||
|
enable_metrics: false
|
||||||
|
enable_registration: false
|
||||||
|
enable_registration_captcha: false
|
||||||
|
enable_registration_without_verification: false
|
||||||
|
enable_room_list_search: true
|
||||||
|
encryption_enabled_by_default_for_room_type: 'off'
|
||||||
|
event_cache_size: 100K
|
||||||
|
federation_rr_transactions_per_room_per_second: 50
|
||||||
|
form_secret: sLKKoFMsQUZgLAW0vU1PQQ8ca1POGMDheurGtKW0uJ20iGqtxR9O7JQ6Knvs44Wi
|
||||||
|
include_profile_data_on_invite: true
|
||||||
|
instance_map: {}
|
||||||
|
limit_profile_requests_to_users_who_share_rooms: false
|
||||||
|
limit_remote_rooms: null
|
||||||
|
listeners:
|
||||||
|
- bind_addresses:
|
||||||
|
- '::'
|
||||||
|
port: 8008
|
||||||
|
resources:
|
||||||
|
- compress: false
|
||||||
|
names:
|
||||||
|
- client
|
||||||
|
tls: false
|
||||||
|
type: http
|
||||||
|
x_forwarded: true
|
||||||
|
log_config: /config/synapse.log.config
|
||||||
|
macaroon_secret_key: Lg8DxGGfy95J367eVJZHLxmqP9XtN4FKdKxWpPvBS3mhviq9at8sw7KHRPkGmyqE
|
||||||
|
manhole_settings: null
|
||||||
|
max_spider_size: 10M
|
||||||
|
max_upload_size: 1024M
|
||||||
|
media_retention:
|
||||||
|
local_media_lifetime: 30d
|
||||||
|
remote_media_lifetime: 7d
|
||||||
|
media_storage_providers: []
|
||||||
|
media_store_path: /media-store
|
||||||
|
metrics_flags: null
|
||||||
|
modules: []
|
||||||
|
oembed: null
|
||||||
|
oidc_providers: null
|
||||||
|
old_signing_keys: null
|
||||||
|
opentracing: null
|
||||||
|
password_config:
|
||||||
|
enabled: true
|
||||||
|
localdb_enabled: true
|
||||||
|
pepper: zfvnYqxe3GTkdJ9BlfZiAqy2zMsjOg02uBTEiWLp2hjQGqlDw33pTSTplE6HoWlF
|
||||||
|
policy: null
|
||||||
|
pid_file: /homeserver.pid
|
||||||
|
presence:
|
||||||
|
enabled: true
|
||||||
|
public_baseurl: http://synapse:8008/
|
||||||
|
push:
|
||||||
|
include_content: true
|
||||||
|
rc_admin_redaction:
|
||||||
|
burst_count: 50
|
||||||
|
per_second: 1
|
||||||
|
rc_federation:
|
||||||
|
concurrent: 3
|
||||||
|
reject_limit: 50
|
||||||
|
sleep_delay: 500
|
||||||
|
sleep_limit: 10
|
||||||
|
window_size: 1000
|
||||||
|
rc_invites:
|
||||||
|
per_issuer:
|
||||||
|
burst_count: 10
|
||||||
|
per_second: 0.3
|
||||||
|
per_room:
|
||||||
|
burst_count: 10
|
||||||
|
per_second: 0.3
|
||||||
|
per_user:
|
||||||
|
burst_count: 5
|
||||||
|
per_second: 0.003
|
||||||
|
rc_joins:
|
||||||
|
local:
|
||||||
|
burst_count: 10
|
||||||
|
per_second: 0.1
|
||||||
|
remote:
|
||||||
|
burst_count: 10
|
||||||
|
per_second: 0.01
|
||||||
|
rc_login:
|
||||||
|
account:
|
||||||
|
burst_count: 3
|
||||||
|
per_second: 0.17
|
||||||
|
address:
|
||||||
|
burst_count: 3
|
||||||
|
per_second: 0.17
|
||||||
|
failed_attempts:
|
||||||
|
burst_count: 3
|
||||||
|
per_second: 0.17
|
||||||
|
rc_message:
|
||||||
|
burst_count: 10
|
||||||
|
per_second: 0.2
|
||||||
|
rc_registration:
|
||||||
|
burst_count: 3
|
||||||
|
per_second: 0.17
|
||||||
|
recaptcha_private_key: ''
|
||||||
|
recaptcha_public_key: ''
|
||||||
|
redaction_retention_period: 5m
|
||||||
|
redis:
|
||||||
|
enabled: false
|
||||||
|
host: null
|
||||||
|
password: null
|
||||||
|
port: 6379
|
||||||
|
registration_requires_token: false
|
||||||
|
registration_shared_secret: jBUKJozByo8s3bvKtYFpB350ZAnxGlzXsDpAZkgOFJuQfKAFHhqbc2dw8D54u4T9
|
||||||
|
report_stats: false
|
||||||
|
require_auth_for_profile_requests: false
|
||||||
|
retention:
|
||||||
|
enabled: true
|
||||||
|
purge_jobs:
|
||||||
|
- interval: 12h
|
||||||
|
room_list_publication_rules:
|
||||||
|
- action: allow
|
||||||
|
alias: '*'
|
||||||
|
room_id: '*'
|
||||||
|
user_id: '*'
|
||||||
|
room_prejoin_state: null
|
||||||
|
saml2_config:
|
||||||
|
sp_config: null
|
||||||
|
user_mapping_provider:
|
||||||
|
config: null
|
||||||
|
server_name: synapse
|
||||||
|
signing_key_path: /config/synapse.signing.key
|
||||||
|
spam_checker: []
|
||||||
|
sso: null
|
||||||
|
stats: null
|
||||||
|
stream_writers: {}
|
||||||
|
templates: null
|
||||||
|
tls_certificate_path: null
|
||||||
|
tls_private_key_path: null
|
||||||
|
trusted_key_servers:
|
||||||
|
- server_name: matrix.org
|
||||||
|
turn_allow_guests: false
|
||||||
|
ui_auth: null
|
||||||
|
url_preview_accept_language:
|
||||||
|
- en-US
|
||||||
|
- en
|
||||||
|
url_preview_enabled: true
|
||||||
|
url_preview_ip_range_blacklist:
|
||||||
|
- 127.0.0.0/8
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 172.16.0.0/12
|
||||||
|
- 192.168.0.0/16
|
||||||
|
- 100.64.0.0/10
|
||||||
|
- 192.0.0.0/24
|
||||||
|
- 169.254.0.0/16
|
||||||
|
- 192.88.99.0/24
|
||||||
|
- 198.18.0.0/15
|
||||||
|
- 192.0.2.0/24
|
||||||
|
- 198.51.100.0/24
|
||||||
|
- 203.0.113.0/24
|
||||||
|
- 224.0.0.0/4
|
||||||
|
- ::1/128
|
||||||
|
- fe80::/10
|
||||||
|
- fc00::/7
|
||||||
|
- 2001:db8::/32
|
||||||
|
- ff00::/8
|
||||||
|
- fec0::/10
|
||||||
|
user_directory: null
|
||||||
|
user_ips_max_age: 5m
|
||||||
|
|
28
testdata/synapse/synapse.log.config
vendored
Normal file
28
testdata/synapse/synapse.log.config
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
version: 1
|
||||||
|
formatters:
|
||||||
|
precise:
|
||||||
|
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||||
|
filters:
|
||||||
|
context:
|
||||||
|
(): synapse.util.logcontext.LoggingContextFilter
|
||||||
|
request: ""
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: precise
|
||||||
|
filters: [context]
|
||||||
|
loggers:
|
||||||
|
synapse:
|
||||||
|
level: INFO
|
||||||
|
shared_secret_authenticator:
|
||||||
|
level: INFO
|
||||||
|
rest_auth_provider:
|
||||||
|
level: INFO
|
||||||
|
synapse.storage.SQL:
|
||||||
|
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||||
|
# information such as access tokens.
|
||||||
|
level: INFO
|
||||||
|
root:
|
||||||
|
level: INFO
|
||||||
|
handlers: [console]
|
||||||
|
|
1
testdata/synapse/synapse.signing.key
vendored
Normal file
1
testdata/synapse/synapse.signing.key
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ed25519 a_FswB rsh+VxdR4YUv6rFM6393VmSEJJxzaDrdwlVwLe2rcRo
|
@@ -24,7 +24,7 @@
|
|||||||
/* Strict Type-Checking Options */
|
/* Strict Type-Checking Options */
|
||||||
"strict": true /* Enable all strict type-checking options. */,
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
|
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
"strictNullChecks": true, /* Enable strict null checks. */
|
||||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||||
|
@@ -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