Compare commits

..

No commits in common. "master" and "0.9.2" have entirely different histories.

90 changed files with 13317 additions and 14542 deletions

View File

@ -1,13 +1,10 @@
# Exclude a bunch of stuff which can make the build context a larger than it needs to be # Exclude a bunch of stuff which can make the build context a larger than it needs to be
tests/ tests/
build/ build/
dist/
lib/ lib/
node_modules/ node_modules/
electron_app/ electron_app/
karma-reports/ karma-reports/
.pnp.cjs
.pnp.loader.mjs
.idea/ .idea/
.tmp/ .tmp/
config.json* config.json*

View File

@ -1,13 +0,0 @@
# EditorConfig https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true

5
.env Normal file
View File

@ -0,0 +1,5 @@
# This setting allows to fix the homeserver.
# If you set this setting, the user will not be able to select
# the server and have to use synapse-admin with this server.
#REACT_APP_SERVER=https://yourmatrixserver.example.com

1
.gitattributes vendored
View File

@ -1 +0,0 @@
yarn*.cjs binary

View File

@ -5,9 +5,6 @@ on:
branches: ["master"] branches: ["master"]
pull_request: pull_request:
permissions:
contents: read
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -20,7 +17,5 @@ jobs:
node-version: "18" node-version: "18"
- name: Install dependencies - name: Install dependencies
run: yarn --immutable run: yarn --immutable
- name: Run checks
run: yarn lint
- name: Run tests - name: Run tests
run: yarn test run: yarn test

View File

@ -1,63 +1,51 @@
name: Create docker image(s) and push to docker hub and ghcr.io name: Create docker image(s) and push to docker hub
# see https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-docker-hub-and-github-packages
on: on:
push: push:
# Sequence of patterns matched against refs/heads # Sequence of patterns matched against refs/heads
# prettier-ignore # prettier-ignore
branches: branches:
# Push events on master branch # Push events on master branch
- master - master
# Sequence of patterns matched against refs/tags # Sequence of patterns matched against refs/tags
tags: tags:
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag - '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
jobs: jobs:
docker: docker:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Calculate docker image tag
- name: Login to GHCR id: set-tag
uses: docker/login-action@v3 run: |
with: case "${GITHUB_REF}" in
registry: ghcr.io refs/heads/master|refs/heads/main)
username: ${{ github.repository_owner }} tag=latest
password: ${{ secrets.GITHUB_TOKEN }} ;;
refs/tags/*)
- name: Extract metadata (tags, labels) for Docker tag=${GITHUB_REF#refs/tags/}
id: meta ;;
uses: docker/metadata-action@v5 *)
with: tag=${GITHUB_SHA}
images: | ;;
awesometechnologies/synapse-admin esac
ghcr.io/${{ github.repository }} echo "::set-output name=tag::$tag"
- name: Build and Push Tag - name: Build and Push Tag
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@ -5,27 +5,22 @@ on:
branches: branches:
- main - main
- master - master
permissions:
contents: write
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout 🛎️ - name: Checkout 🛎️
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-tags: true
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "18"
- name: Install and Build 🔧 - name: Install and Build 🔧
run: | run: |
yarn install --immutable yarn install --immutable
yarn build --base=/synapse-admin yarn build
- name: Deploy 🚀 - name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4.7.3 uses: JamesIves/github-pages-deploy-action@v4.5.0
with: with:
branch: gh-pages branch: gh-pages
folder: dist folder: build

View File

@ -16,14 +16,15 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "18"
- run: yarn install --immutable - run: yarn install --immutable
- run: yarn build - run: yarn build
- run: | - run: |
version=`git describe --dirty --tags || echo unknown` version=`git describe --dirty --tags || echo unknown`
cp -r dist synapse-admin-$version mkdir -p dist
cp -r build synapse-admin-$version
tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version tar chvzf dist/synapse-admin-$version.tar.gz synapse-admin-$version
- uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 - uses: softprops/action-gh-release@3198ee18f814cdf787321b4a32a26ddbf37acc52
with: with:
files: dist/*.tar.gz files: dist/*.tar.gz
env: env:

51
.github/workflows/test-docker-image.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Test docker image creation
on:
push:
# Sequence of patterns matched against refs/heads
# prettier-ignore
branches:
# Push events on branch fix_docker_cd
- fix_docker_cd
# Sequence of patterns matched against refs/tags
tags:
- '[0-9]+\.[0-9]+\.[0-9]+' # Push events to 0.X.X tag
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: Calculate docker image tag
id: set-tag
run: |
case "${GITHUB_REF}" in
refs/heads/master|refs/heads/main)
tag=latest
;;
refs/tags/*)
tag=${GITHUB_REF#refs/tags/}
;;
*)
tag=${GITHUB_SHA}
;;
esac
echo "::set-output name=tag::$tag"
- name: Build and Push Tag
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: "awesometechnologies/synapse-admin:${{ steps.set-tag.outputs.tag }}"
platforms: linux/amd64,linux/arm64

200
.gitignore vendored
View File

@ -1,193 +1,23 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,react,visualstudiocode
### Node ### # dependencies
# Logs /node_modules
logs /.pnp
*.log .pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # testing
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json /coverage
# Runtime data # production
pids /build
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # misc
lib-cov .DS_Store
.env.local
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/) npm-debug.log*
.cache yarn-debug.log*
.parcel-cache yarn-error.log*
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### yarn ###
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
.yarn/*
!.yarn/releases
!.yarn/patches
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
# if you are NOT using Zero-installs, then:
# comment the following lines
!.yarn/cache
# and uncomment the following lines
# .pnp.*
# End of https://www.toptal.com/developers/gitignore/api/node,yarn,react,visualstudiocode

View File

@ -1,2 +0,0 @@
.vscode
.yarn

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid"
}

View File

@ -1,7 +0,0 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

10
.vscode/settings.json vendored
View File

@ -1,10 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

File diff suppressed because one or more lines are too long

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = absRequire(`eslint/use-at-your-own-risk`);

View File

@ -1,14 +0,0 @@
{
"name": "eslint",
"version": "8.57.0-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

View File

@ -1,5 +0,0 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = absRequire(`prettier/bin/prettier.cjs`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier your application uses
module.exports = absRequire(`prettier`);

View File

@ -1,7 +0,0 @@
{
"name": "prettier",
"version": "3.2.5-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View File

@ -1,225 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -1,225 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View File

@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript your application uses
module.exports = absRequire(`typescript`);

View File

@ -1,10 +0,0 @@
{
"name": "typescript",
"version": "5.4.5-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

View File

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

View File

@ -1,26 +1,19 @@
# Builder # Builder
FROM node:lts AS builder FROM node:lts as builder
LABEL org.opencontainers.image.url=https://github.com/Awesome-Technologies/synapse-admin org.opencontainers.image.source=https://github.com/Awesome-Technologies/synapse-admin
# Base path for synapse admin ARG REACT_APP_SERVER
ARG BASE_PATH=./
WORKDIR /src WORKDIR /src
# Copy .yarn directory to the working directory (must be on a separate line!)
# Use https://docs.docker.com/engine/reference/builder/#copy---parents when available
COPY .yarn .yarn
COPY package.json .yarnrc.yml yarn.lock ./
# Disable telemetry and install packages
RUN yarn config set enableTelemetry 0 && yarn install --immutable --network-timeout=300000
COPY . /src COPY . /src
RUN yarn build --base=$BASE_PATH RUN yarn --network-timeout=300000 install --immutable
RUN REACT_APP_SERVER=$REACT_APP_SERVER yarn build
# App # App
FROM nginx:stable-alpine FROM nginx:alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/build /app
RUN rm -rf /usr/share/nginx/html \ RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html && ln -s /app /usr/share/nginx/html

View File

@ -13,7 +13,7 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
### Supported Synapse ### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected! It needs at least [Synapse](https://github.com/element-hq/synapse) v1.52.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`. 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). See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
@ -64,6 +64,11 @@ You have three options:
- download dependencies: `yarn install` - download dependencies: `yarn install`
- start web server: `yarn start` - start web server: `yarn start`
You can fix the homeserver, so that the user can no longer define it himself.
Either you define it at startup (e.g. `REACT_APP_SERVER=https://yourmatrixserver.example.com yarn start`)
or by editing it in the [.env](.env) file. See also the
[documentation](https://create-react-app.dev/docs/adding-custom-environment-variables/).
#### Steps for 3) #### 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` - 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`
@ -71,16 +76,19 @@ You have three options:
> 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. > 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 ```yml
version: "3"
services: services:
synapse-admin: synapse-admin:
container_name: synapse-admin container_name: synapse-admin
hostname: synapse-admin hostname: synapse-admin
build: build:
context: https://github.com/Awesome-Technologies/synapse-admin.git context: https://github.com/Awesome-Technologies/synapse-admin.git
args: # args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# - NODE_OPTIONS="--max_old_space_size=1024" # - NODE_OPTIONS="--max_old_space_size=1024"
# - BASE_PATH="/synapse-admin" # # see #266, PUBLIC_URL must be without surrounding quotation marks
# - PUBLIC_URL=/synapse-admin
# - REACT_APP_SERVER="https://matrix.example.com"
ports: ports:
- "8080:80" - "8080:80"
restart: unless-stopped restart: unless-stopped
@ -88,81 +96,11 @@ You have three options:
- browse to http://localhost:8080 - browse to http://localhost:8080
### Restricting available homeserver
You can restrict the homeserver(s), so that the user can no longer define it himself.
Edit `config.json` to restrict either to a single homeserver:
```json
{
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
}
```
or to a list of homeservers:
```json
{
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
}
```
The `config.json` can be injected into a Docker container using a bind mount.
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
```
### Serving Synapse-Admin on a different path
The path prefix where synapse-admin is served can only be changed during the build step.
If you downloaded the source code, use `yarn build --base=/my-prefix` to set a path prefix.
If you want to build your own Docker container, use the `BASE_PATH` argument.
We do not support directly changing the path where Synapse-Admin is served in the pre-built Docker container. Instead please use a reverse proxy if you need to move Synapse-Admin to a different base path. If you want to serve multiple applications with different paths on the same domain, you need a reverse proxy anyway.
Example for Traefik:
`docker-compose.yml`
```yml
services:
traefik:
image: traefik:mimolette
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
synapse-admin:
image: awesometechnologies/synapse-admin:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse-admin.rule=Host(`example.com`)&&PathPrefix(`/admin`)"
- "traefik.http.routers.synapse-admin.middlewares=admin,admin_path"
- "traefik.http.middlewares.admin.redirectregex.regex=^(.*)/admin/?"
- "traefik.http.middlewares.admin.redirectregex.replacement=$${1}/admin/"
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
```
## Screenshots ## Screenshots
![Screenshots](./screenshots.jpg) ![Screenshots](./screenshots.jpg)
## Development ## Development
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE - Use `yarn test` to run all style, lint and unit tests
- Use `yarn lint` to run all style and linter checks
- Use `yarn test` to run all unit tests
- Use `yarn fix` to fix the coding style - Use `yarn fix` to fix the coding style

View File

@ -1,21 +1,26 @@
version: "3"
services: 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: awesometechnologies/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/Awesome-Technologies/synapse-admin.git
# args: # args:
# - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 # if you're building on an architecture other than amd64, make sure
# if you're building on an architecture other than amd64, make sure # to define a maximum ram for node. otherwise the build will fail.
# to define a maximum ram for node. otherwise the build will fail. # - NODE_OPTIONS="--max_old_space_size=1024"
# - NODE_OPTIONS="--max_old_space_size=1024" # default is .
# - BASE_PATH="/synapse-admin" # - PUBLIC_URL=/synapse-admin
# You can use a fixed homeserver, so that the user can no longer
# define it himself
# - REACT_APP_SERVER="https://matrix.example.com"
ports: ports:
- "8080:80" - "8080:80"
restart: unless-stopped restart: unless-stopped

View File

@ -1,132 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Synapse-Admin"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="./manifest.json" />
<link rel="shortcut icon" href="./favicon.ico" />
<title>Synapse-Admin</title>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
.loader-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: #fafafa;
}
/* CSS Spinner from https://projects.lukehaas.me/css-loaders/ */
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
}
.loader {
color: #283593;
font-size: 11px;
text-indent: -99999em;
margin: 55px auto;
position: relative;
width: 10em;
height: 10em;
box-shadow: inset 0 0 0 1em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.loader:before,
.loader:after {
position: absolute;
content: '';
}
.loader:before {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 10.2em 0 0 10.2em;
top: -0.1em;
left: -0.1em;
-webkit-transform-origin: 5.2em 5.1em;
transform-origin: 5.2em 5.1em;
-webkit-animation: load2 2s infinite ease 1.5s;
animation: load2 2s infinite ease 1.5s;
}
.loader:after {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 0 10.2em 10.2em 0;
top: -0.1em;
left: 5.1em;
-webkit-transform-origin: 0px 5.1em;
transform-origin: 0px 5.1em;
-webkit-animation: load2 2s infinite ease;
animation: load2 2s infinite ease;
}
@-webkit-keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div class="loader-container">
<div class="loader">Loading...</div>
</div>
</div>
<script type="module" src="/src/index.tsx"></script>
<footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
style="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>
<script>document.getElementById("version").textContent = __SYNAPSE_ADMIN_VERSION__</script>
</html>

View File

@ -1,13 +0,0 @@
import type { JestConfigWithTsJest } from "ts-jest";
const config: JestConfigWithTsJest = {
preset: "ts-jest",
testEnvironment: "jest-fixed-jsdom",
collectCoverage: true,
coveragePathIgnorePatterns: ["node_modules", "dist"],
coverageDirectory: "<rootDir>/coverage/",
coverageReporters: ["html", "text", "text-summary", "cobertura"],
extensionsToTreatAsEsm: [".ts", ".tsx"],
setupFilesAfterEnv: ["<rootDir>/src/jest.setup.ts"],
};
export default config;

View File

@ -1,8 +1,7 @@
{ {
"name": "synapse-admin", "name": "synapse-admin",
"version": "0.10.3", "version": "0.9.2",
"description": "Admin GUI for the Matrix.org server Synapse", "description": "Admin GUI for the Matrix.org server Synapse",
"type": "module",
"author": "Awesome Technologies Innovationslabor GmbH", "author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0", "license": "Apache-2.0",
"homepage": ".", "homepage": ".",
@ -10,146 +9,47 @@
"type": "git", "type": "git",
"url": "https://github.com/Awesome-Technologies/synapse-admin" "url": "https://github.com/Awesome-Technologies/synapse-admin"
}, },
"packageManager": "yarn@4.4.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.7.0",
"@mui/system": "^7.1.0",
"@mui/utils": "^7.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^15.0.2",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.12",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.0.0",
"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-config-react-app": "^7.0.1",
"eslint-plugin-jsx-a11y": "^6.9.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-yaml": "^1.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-fixed-jsdom": "^0.0.9", "prettier": "^3.2.5"
"prettier": "^3.3.3",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.3.4",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5",
"vite-plugin-version-mark": "^0.1.0"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@mui/icons-material": "^5.15.15",
"@emotion/styled": "^11.14.0", "@mui/material": "^5.15.15",
"@haleos/ra-language-german": "^1.0.0", "@mui/styles": "^5.15.15",
"@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tanstack/react-query": "^5.59.12",
"history": "^5.3.0",
"lodash": "^4.17.21",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"query-string": "^7.1.3", "ra-language-chinese": "^2.0.10",
"ra-core": "^5.8.3", "ra-language-french": "^4.16.15",
"ra-i18n-polyglot": "^5.8.3", "ra-language-german": "^3.13.4",
"ra-language-english": "^5.8.3",
"ra-language-farsi": "^5.0.0",
"ra-language-french": "^5.8.3",
"ra-language-italian": "^3.13.1", "ra-language-italian": "^3.13.1",
"ra-language-russian": "^4.14.2", "ra-language-farsi": "^4.2.0",
"react": "^18.3.1", "react": "^18.0.0",
"react-admin": "^5.8.3", "react-admin": "^4.16.15",
"react-dom": "^18.3.1", "react-dom": "^18.0.0",
"react-hook-form": "^7.52.1", "react-scripts": "^5.0.1"
"react-is": "^18.3.1",
"react-router": "^7.6.1",
"react-router-dom": "^7.6.1"
}, },
"scripts": { "scripts": {
"start": "vite serve", "start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",
"build": "vite build", "build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build",
"lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .", "fix:other": "yarn prettier --write",
"fix": "yarn lint --fix", "fix:code": "yarn test:lint --fix",
"test": "yarn jest", "fix": "yarn fix:code && yarn fix:other",
"test:watch": "yarn jest --watch" "prettier": "prettier --ignore-path .gitignore \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
"test:code": "react-scripts test",
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
"test:style": "yarn prettier --list-different",
"test": "yarn test:style && yarn test:lint && yarn test:code",
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"env": { "extends": "react-app"
"browser": true
},
"plugins": [
"import",
"prettier",
"unused-imports",
"@typescript-eslint",
"yaml"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:import/typescript",
"plugin:yaml/legacy"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"root": true,
"rules": {
"prettier/prettier": "error",
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"**/vite.config.ts",
"**/jest.setup.ts",
"**/*.test.ts",
"**/*.test.tsx"
]
}
],
"import/order": [
"error",
{
"alphabetize": {
"order": "asc",
"caseInsensitive": false
},
"newlines-between": "always",
"groups": [
"external",
"builtin",
"internal",
[
"parent",
"sibling",
"index"
]
]
}
],
"unused-imports/no-unused-imports-ts": 2
}
},
"prettier": {
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@ -1 +0,0 @@
{}

49
public/index.html Normal file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Synapse-Admin"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Synapse-Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
style="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>(%REACT_APP_VERSION%)</b> by Awesome Technologies Innovationslabor GmbH
</a>
</footer>
</body>
</html>

72
src/App.jsx Normal file
View File

@ -0,0 +1,72 @@
import React from "react";
import {
Admin,
CustomRoutes,
Resource,
resolveBrowserLocale,
} from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import users from "./components/users";
import rooms from "./components/rooms";
import userMediaStats from "./components/statistics";
import reports from "./components/EventReports";
import roomDirectory from "./components/RoomDirectory";
import destinations from "./components/destinations";
import registrationToken from "./components/RegistrationTokens";
import LoginPage from "./components/LoginPage";
import { ImportFeature } from "./components/ImportFeature";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
import italianMessages from "./i18n/it";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
);
export default App;

View File

@ -1,7 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
import App from "./App"; import App from "./App";
describe("App", () => { describe("App", () => {

View File

@ -1,85 +0,0 @@
import { merge } from "lodash";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
import { Route } from "react-router-dom";
import { ImportFeature } from "./components/ImportFeature";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import italianMessages from "./i18n/it";
import russianMessages from "./i18n/ru";
import chineseMessages from "./i18n/zh";
import LoginPage from "./pages/LoginPage";
import destinations from "./resources/destinations";
import registrationToken from "./resources/registration_tokens";
import reports from "./resources/reports";
import roomDirectory from "./resources/room_directory";
import rooms from "./resources/rooms";
import userMediaStats from "./resources/user_media_statistics";
import users from "./resources/users";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
ru: russianMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en),
resolveBrowserLocale(),
[
{ locale: "en", name: "English" },
{ locale: "de", name: "Deutsch" },
{ locale: "fr", name: "Français" },
{ locale: "it", name: "Italiano" },
{ locale: "fa", name: "Persian(فارسی)" },
{ locale: "ru", name: "Russian(Русский)" },
{ locale: "zh", name: "简体中文" },
]
);
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
</QueryClientProvider>
);
export default App;

View File

@ -1,9 +0,0 @@
import { createContext, useContext } from "react";
interface AppContextType {
restrictBaseUrl: string | string[];
}
export const AppContext = createContext({});
export const useAppContext = () => useContext(AppContext) as AppContextType;

View File

@ -1,5 +1,5 @@
import { get } from "lodash"; import React from "react";
import get from "lodash/get";
import { Avatar } from "@mui/material"; import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin"; import { useRecordContext } from "react-admin";
@ -7,7 +7,16 @@ const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest); const record = useRecordContext(rest);
const src = get(record, source)?.toString(); const src = get(record, source)?.toString();
const { alt, classes, sizes, sx, variant } = rest; const { alt, classes, sizes, sx, variant } = rest;
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}
/>
);
}; };
export default AvatarField; export default AvatarField;

View File

@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react"; import React from "react";
import { RecordContextProvider } from "react-admin"; import { RecordContextProvider } from "react-admin";
import { render, screen } from "@testing-library/react";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
describe("AvatarField", () => { describe("AvatarField", () => {

View File

@ -1,18 +1,13 @@
import PageviewIcon from "@mui/icons-material/Pageview"; import React from "react";
import ViewListIcon from "@mui/icons-material/ViewList";
import ReportIcon from "@mui/icons-material/Warning";
import { import {
Datagrid, Datagrid,
DateField, DateField,
DeleteButton, DeleteButton,
List, List,
ListProps,
NumberField, NumberField,
Pagination, Pagination,
ReferenceField, ReferenceField,
ResourceProps,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -20,13 +15,24 @@ import {
useRecordContext, useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import PageviewIcon from "@mui/icons-material/Pageview";
import ReportIcon from "@mui/icons-material/Warning";
import ViewListIcon from "@mui/icons-material/ViewList";
import { DATE_FORMAT } from "../components/date"; const date_format = {
import { MXCField } from "../components/media"; year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const ReportPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = (props: ShowProps) => { export const ReportShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show {...props} actions={<ReportShowActions />}> <Show {...props} actions={<ReportShowActions />}>
@ -37,21 +43,43 @@ export const ReportShow = (props: ShowProps) => {
})} })}
icon={<ViewListIcon />} icon={<ViewListIcon />}
> >
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} /> <DateField
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="user_id" reference="users"> <ReferenceField source="user_id" reference="users">
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
<NumberField source="score" /> <NumberField source="score" />
<TextField source="reason" /> <TextField source="reason" />
<TextField source="name" /> <TextField source="name" />
<TextField source="canonical_alias" label="resources.rooms.fields.canonical_alias" /> <TextField
<ReferenceField source="room_id" reference="rooms" link="show" label="resources.rooms.fields.room_id"> source="canonical_alias"
label="resources.rooms.fields.canonical_alias"
/>
<ReferenceField
source="room_id"
reference="rooms"
link="show"
label="resources.rooms.fields.room_id"
>
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
</Tab> </Tab>
<Tab label="synapseadmin.reports.tabs.detail" icon={<PageviewIcon />} path="detail"> <Tab
<DateField source="event_json.origin_server_ts" showTime options={DATE_FORMAT} sortable={true} /> label="synapseadmin.reports.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<DateField
source="event_json.origin_server_ts"
showTime
options={date_format}
sortable={true}
/>
<ReferenceField source="sender" reference="users"> <ReferenceField source="sender" reference="users">
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
@ -61,12 +89,13 @@ export const ReportShow = (props: ShowProps) => {
<TextField source="event_json.type" /> <TextField source="event_json.type" />
<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" />
<MXCField 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" />
<TextField source="event_json.content.device_id" label="resources.devices.fields.device_id" /> <TextField
source="event_json.content.device_id"
label="resources.devices.fields.device_id"
/>
</Tab> </Tab>
</TabbedShowLayout> </TabbedShowLayout>
</Show> </Show>
@ -88,11 +117,20 @@ const ReportShowActions = () => {
); );
}; };
export const ReportList = (props: ListProps) => ( export const ReportList = props => (
<List {...props} pagination={<ReportPagination />} sort={{ field: "received_ts", order: "DESC" }}> <List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
>
<Datagrid rowClick="show" bulkActionButtons={false}> <Datagrid rowClick="show" bulkActionButtons={false}>
<TextField source="id" sortable={false} /> <TextField source="id" sortable={false} />
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={true} /> <DateField
source="received_ts"
showTime
options={date_format}
sortable={true}
/>
<TextField sortable={false} source="user_id" /> <TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" /> <TextField sortable={false} source="name" />
<TextField sortable={false} source="score" /> <TextField sortable={false} source="score" />
@ -100,7 +138,7 @@ export const ReportList = (props: ListProps) => (
</List> </List>
); );
const resource: ResourceProps = { const resource = {
name: "reports", name: "reports",
icon: ReportIcon, icon: ReportIcon,
list: ReportList, list: ReportList,

View File

@ -1,6 +1,6 @@
import { parse as parseCsv, unparse as unparseCsv, ParseResult } from "papaparse"; import React, { useState } from "react";
import { ChangeEvent, useState } from "react"; import { useDataProvider, useNotify, Title } from "react-admin";
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
import { import {
Button, Button,
Card, Card,
@ -12,65 +12,36 @@ import {
FormControlLabel, FormControlLabel,
NativeSelect, NativeSelect,
} from "@mui/material"; } from "@mui/material";
import { DataProvider, useTranslate } from "ra-core"; import { useTranslate } from "ra-core";
import { useDataProvider, useNotify, RaRecord, Title } from "react-admin"; import { generateRandomUser } from "./users";
import { generateRandomMxId, generateRandomPassword } from "../synapse/synapse";
const LOGGING = true; const LOGGING = true;
const expectedFields = ["id", "displayname"].sort(); const expectedFields = ["id", "displayname"].sort();
const optionalFields = [
"user_type",
"guest",
"admin",
"deactivated",
"avatar_url",
"password",
].sort();
function TranslatableOption({ value, text }) { function TranslatableOption({ value, text }) {
const translate = useTranslate(); const translate = useTranslate();
return <option value={value}>{translate(text)}</option>; return <option value={value}>{translate(text)}</option>;
} }
type Progress = {
done: number;
limit: number;
} | null;
interface ImportLine {
id: string;
displayname: string;
user_type?: string;
name?: string;
deactivated?: boolean;
guest?: boolean;
admin?: boolean;
is_admin?: boolean;
password?: string;
avatar_url?: string;
}
interface ChangeStats {
total: number;
id: number;
is_guest: number;
admin: number;
password: number;
}
interface ImportResult {
skippedRecords: RaRecord[];
erroredRecords: RaRecord[];
succeededRecords: RaRecord[];
totalRecordCount: number;
changeStats: ChangeStats;
wasDryRun: boolean;
}
const FilePicker = () => { const FilePicker = () => {
const [values, setValues] = useState<ImportLine[]>([]); const [values, setValues] = useState(null);
const [error, setError] = useState<string | string[] | null>(null); const [error, setError] = useState(null);
const [stats, setStats] = useState<ChangeStats | null>(null); const [stats, setStats] = useState(null);
const [dryRun, setDryRun] = useState(true); const [dryRun, setDryRun] = useState(true);
const [progress, setProgress] = useState<Progress>(null); const [progress, setProgress] = useState(null);
const [importResults, setImportResults] = useState<ImportResult | null>(null); const [importResults, setImportResults] = useState(null);
const [skippedRecords, setSkippedRecords] = useState<string>(""); const [skippedRecords, setSkippedRecords] = useState(null);
const [conflictMode, setConflictMode] = useState("stop"); const [conflictMode, setConflictMode] = useState("stop");
const [passwordMode, setPasswordMode] = useState(true); const [passwordMode, setPasswordMode] = useState(true);
@ -81,15 +52,14 @@ const FilePicker = () => {
const dataProvider = useDataProvider(); const dataProvider = useDataProvider();
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => { const onFileChange = async e => {
if (progress !== null) return; if (progress !== null) return;
setValues([]); setValues(null);
setError(null); setError(null);
setStats(null); setStats(null);
setImportResults(null); setImportResults(null);
const file = e.target.files ? e.target.files[0] : null; const file = e.target.files ? e.target.files[0] : null;
if (!file) return;
/* Let's refuse some unreasonably big files instead of freezing /* Let's refuse some unreasonably big files instead of freezing
* up the browser */ * up the browser */
if (file.size > 100000000) { if (file.size > 100000000) {
@ -101,12 +71,12 @@ const FilePicker = () => {
return; return;
} }
try { try {
parseCsv<ImportLine>(file, { parseCsv(file, {
header: true, header: true,
skipEmptyLines: true /* especially for a final EOL in the csv file */, skipEmptyLines: true /* especially for a final EOL in the csv file */,
complete: result => { complete: result => {
if (result.errors) { if (result.error) {
setError(result.errors.map(e => e.toString())); setError(result.error);
} }
/* Papaparse is very lenient, we may be able to salvage /* Papaparse is very lenient, we may be able to salvage
* the data in the file. */ * the data in the file. */
@ -114,17 +84,32 @@ const FilePicker = () => {
}, },
}); });
} catch { } catch {
setError("Unknown error"); setError(true);
return null; return null;
} }
}; };
const verifyCsv = ({ data, meta, errors }: ParseResult<ImportLine>, { setValues, setStats, setError }) => { const verifyCsv = (
{ data, meta, errors },
{ 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)); let eF = Array.from(expectedFields);
let oF = Array.from(optionalFields);
if (missingFields.length > 0) { meta.fields.forEach(name => {
setError(translate("import_users.error.required_field", { field: missingFields[0] })); if (eF.includes(name)) {
eF = eF.filter(v => v !== name);
}
if (oF.includes(name)) {
oF = oF.filter(v => v !== name);
}
});
if (eF.length !== 0) {
setError(
translate("import_users.error.required_field", { field: eF[0] })
);
return false; return false;
} }
@ -134,7 +119,7 @@ const FilePicker = () => {
/* Collect some stats to prevent sneaky csv files from adding admin /* Collect some stats to prevent sneaky csv files from adding admin
users or something. users or something.
*/ */
const stats = { let stats = {
user_types: { default: 0 }, user_types: { default: 0 },
is_guest: 0, is_guest: 0,
admin: 0, admin: 0,
@ -146,7 +131,6 @@ const FilePicker = () => {
total: data.length, total: data.length,
}; };
const errorMessages = errors.map(e => e.message);
data.forEach((line, idx) => { data.forEach((line, idx) => {
if (line.user_type === undefined || line.user_type === "") { if (line.user_type === undefined || line.user_type === "") {
stats.user_types.default++; stats.user_types.default++;
@ -157,13 +141,14 @@ const FilePicker = () => {
* resource so it gives sensible field names and doesn't duplicate * resource so it gives sensible field names and doesn't duplicate
* id as "name"? * id as "name"?
*/ */
if (meta.fields?.includes("name")) { if (meta.fields.includes("name")) {
delete line.name; delete line.name;
} }
if (meta.fields?.includes("user_type")) { if (meta.fields.includes("user_type")) {
delete line.user_type; delete line.user_type;
} }
if (meta.fields?.includes("is_admin")) { if (meta.fields.includes("is_admin")) {
line.admin = line.is_admin;
delete line.is_admin; delete line.is_admin;
} }
@ -173,7 +158,7 @@ const FilePicker = () => {
line[f] = true; // we need true booleans instead of strings line[f] = true; // we need true booleans instead of strings
} else { } else {
if (line[f] !== "false" && line[f] !== "") { if (line[f] !== "false" && line[f] !== "") {
errorMessages.push( errors.push(
translate("import_users.error.invalid_value", { translate("import_users.error.invalid_value", {
field: f, field: f,
row: idx, row: idx,
@ -197,8 +182,8 @@ const FilePicker = () => {
} }
}); });
if (errorMessages.length > 0) { if (errors.length > 0) {
setError(errorMessages); setError(errors);
} }
setStats(stats); setStats(stats);
setValues(data); setValues(data);
@ -206,7 +191,7 @@ const FilePicker = () => {
return true; return true;
}; };
const runImport = async () => { const runImport = async _e => {
if (progress !== null) { if (progress !== null) {
notify("import_users.errors.already_in_progress"); notify("import_users.errors.already_in_progress");
return; return;
@ -235,40 +220,61 @@ const FilePicker = () => {
// which doesn't look very good. // which doesn't look very good.
const doImport = async ( const doImport = async (
dataProvider: DataProvider, dataProvider,
data: ImportLine[], data,
conflictMode: string, conflictMode,
passwordMode: boolean, passwordMode,
useridMode: string, useridMode,
dryRun: boolean, dryRun,
setProgress: (progress: Progress) => void, setProgress,
setError: (message: string) => void setError
): Promise<ImportResult> => { ) => {
const skippedRecords: ImportLine[] = []; let skippedRecords = [];
const erroredRecords: ImportLine[] = []; let erroredRecords = [];
const succeededRecords: ImportLine[] = []; let succeededRecords = [];
const changeStats: ChangeStats = { let changeStats = {
total: 0, toAdmin: 0,
id: 0, toGuest: 0,
is_guest: 0, toRegular: 0,
admin: 0, replacedPassword: 0,
password: 0,
}; };
let entriesDone = 0; let entriesDone = 0;
const entriesCount = data.length; let entriesCount = data.length;
try { try {
setProgress({ done: entriesDone, limit: entriesCount }); setProgress({ done: entriesDone, limit: entriesCount });
for (const entry of data) { for (const entry of data) {
const userRecord = { ...entry }; let userRecord = {};
let overwriteData = {};
// 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 (
userRecord.id = generateRandomMxId(); useridMode === "ignore" ||
} entry.id === undefined ||
if (passwordMode === false || entry.password === undefined) { entry.password === undefined ||
userRecord.password = generateRandomPassword(); passwordMode === false
) {
overwriteData = generateRandomUser();
// Ignoring IDs or the entry lacking an ID means we keep the
// ID field in the overwrite data.
if (!(useridMode === "ignore" || entry.id === undefined)) {
delete overwriteData.id;
}
// Not using passwords from the csv or this entry lacking a password
// means we keep the password field in the overwrite data.
if (
!(
passwordMode === false ||
entry.password === undefined ||
entry.password === ""
)
) {
delete overwriteData.password;
}
} }
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */ /* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
Object.assign(userRecord, entry);
Object.assign(userRecord, overwriteData);
/* 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.
* If the mode is "stop", we will not continue adding more records, and * If the mode is "stop", we will not continue adding more records, and
@ -294,11 +300,14 @@ const FilePicker = () => {
* We do a simple retry loop so that an accidental hit on an existing ID * We do a simple retry loop so that an accidental hit on an existing ID
* doesn't trip us up. * doesn't trip us up.
*/ */
if (LOGGING) console.log("will check for existence of record " + JSON.stringify(userRecord)); if (LOGGING)
console.log(
"will check for existence of record " + JSON.stringify(userRecord)
);
let retries = 0; let retries = 0;
const submitRecord = (recordData: ImportLine) => { const submitRecord = recordData => {
return dataProvider.getOne("users", { id: recordData.id }).then( return dataProvider.getOne("users", { id: recordData.id }).then(
async () => { async _alreadyExists => {
if (LOGGING) console.log("already existed"); if (LOGGING) console.log("already existed");
if (useridMode === "update" || conflictMode === "skip") { if (useridMode === "update" || conflictMode === "skip") {
@ -310,8 +319,9 @@ const FilePicker = () => {
}) })
); );
} else { } else {
const overwriteData = generateRandomUser();
const newRecordData = Object.assign({}, recordData, { const newRecordData = Object.assign({}, recordData, {
id: generateRandomMxId(), id: overwriteData.id,
}); });
retries++; retries++;
if (retries > 512) { if (retries > 512) {
@ -322,8 +332,15 @@ const FilePicker = () => {
} }
} }
}, },
async () => { async _okToSubmit => {
if (LOGGING) console.log("OK to create record " + recordData.id + " (" + recordData.displayname + ")."); if (LOGGING)
console.log(
"OK to create record " +
recordData.id +
" (" +
recordData.displayname +
")."
);
if (!dryRun) { if (!dryRun) {
await dataProvider.create("users", { data: recordData }); await dataProvider.create("users", { data: recordData });
@ -343,7 +360,7 @@ const FilePicker = () => {
setError( setError(
translate("import_users.error.at_entry", { translate("import_users.error.at_entry", {
entry: entriesDone + 1, entry: entriesDone + 1,
message: e instanceof Error ? e.message : String(e), message: e.message,
}) })
); );
setProgress(null); setProgress(null);
@ -370,7 +387,7 @@ const FilePicker = () => {
element.click(); element.click();
}; };
const onConflictModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => { const onConflictModeChanged = async e => {
if (progress !== null) { if (progress !== null) {
return; return;
} }
@ -379,7 +396,7 @@ const FilePicker = () => {
setConflictMode(value); setConflictMode(value);
}; };
const onPasswordModeChange = (e: ChangeEvent<HTMLInputElement>) => { const onPasswordModeChange = e => {
if (progress !== null) { if (progress !== null) {
return; return;
} }
@ -387,7 +404,7 @@ const FilePicker = () => {
setPasswordMode(e.target.checked); setPasswordMode(e.target.checked);
}; };
const onUseridModeChanged = async (e: ChangeEvent<HTMLSelectElement>) => { const onUseridModeChanged = async e => {
if (progress !== null) { if (progress !== null) {
return; return;
} }
@ -396,11 +413,11 @@ const FilePicker = () => {
setUseridMode(value); setUseridMode(value);
}; };
const onDryRunModeChanged = (e: ChangeEvent<HTMLInputElement>) => { const onDryRunModeChanged = ev => {
if (progress !== null) { if (progress !== null) {
return; return;
} }
setDryRun(e.target.checked); setDryRun(ev.target.checked);
}; };
// render individual small components // render individual small components
@ -408,11 +425,28 @@ const FilePicker = () => {
const statsCards = stats && const statsCards = stats &&
!importResults && [ !importResults && [
<Container> <Container>
<CardHeader title={translate("import_users.cards.importstats.header")} /> <CardHeader
title={translate("import_users.cards.importstats.header")}
/>
<CardContent> <CardContent>
<div>{translate("import_users.cards.importstats.users_total", stats.total)}</div> <div>
<div>{translate("import_users.cards.importstats.guest_count", stats.is_guest)}</div> {translate(
<div>{translate("import_users.cards.importstats.admin_count", stats.admin)}</div> "import_users.cards.importstats.users_total",
stats.total
)}
</div>
<div>
{translate(
"import_users.cards.importstats.guest_count",
stats.is_guest
)}
</div>
<div>
{translate(
"import_users.cards.importstats.admin_count",
stats.admin
)}
</div>
</CardContent> </CardContent>
</Container>, </Container>,
<Container> <Container>
@ -425,9 +459,19 @@ const FilePicker = () => {
</div> </div>
{stats.id > 0 ? ( {stats.id > 0 ? (
<div> <div>
<NativeSelect onChange={onUseridModeChanged} value={useridMode} disabled={progress !== null}> <NativeSelect
<TranslatableOption value="ignore" text="import_users.cards.ids.mode.ignore" /> onChange={onUseridModeChanged}
<TranslatableOption value="update" text="import_users.cards.ids.mode.update" /> value={useridMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="ignore"
text="import_users.cards.ids.mode.ignore"
/>
<TranslatableOption
value="update"
text="import_users.cards.ids.mode.update"
/>
</NativeSelect> </NativeSelect>
</div> </div>
) : ( ) : (
@ -441,13 +485,20 @@ const FilePicker = () => {
<div> <div>
{stats.password === stats.total {stats.password === stats.total
? translate("import_users.cards.passwords.all_passwords_present") ? translate("import_users.cards.passwords.all_passwords_present")
: translate("import_users.cards.passwords.count_passwords_present", stats.password)} : translate(
"import_users.cards.passwords.count_passwords_present",
stats.password
)}
</div> </div>
{stats.password > 0 ? ( {stats.password > 0 ? (
<div> <div>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox checked={passwordMode} disabled={progress !== null} onChange={onPasswordModeChange} /> <Checkbox
checked={passwordMode}
enabled={(progress !== null).toString()}
onChange={onPasswordModeChange}
/>
} }
label={translate("import_users.cards.passwords.use_passwords")} label={translate("import_users.cards.passwords.use_passwords")}
/> />
@ -459,21 +510,31 @@ const FilePicker = () => {
</Container>, </Container>,
]; ];
const conflictCards = stats && !importResults && ( let conflictCards = stats && !importResults && (
<Container> <Container>
<CardHeader title={translate("import_users.cards.conflicts.header")} /> <CardHeader title={translate("import_users.cards.conflicts.header")} />
<CardContent> <CardContent>
<div> <div>
<NativeSelect onChange={onConflictModeChanged} value={conflictMode} disabled={progress !== null}> <NativeSelect
<TranslatableOption value="stop" text="import_users.cards.conflicts.mode.stop" /> onChange={onConflictModeChanged}
<TranslatableOption value="skip" text="import_users.cards.conflicts.mode.skip" /> value={conflictMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="stop"
text="import_users.cards.conflicts.mode.stop"
/>
<TranslatableOption
value="skip"
text="import_users.cards.conflicts.mode.skip"
/>
</NativeSelect> </NativeSelect>
</div> </div>
</CardContent> </CardContent>
</Container> </Container>
); );
const errorCards = error && ( let errorCards = error && (
<Container> <Container>
<CardHeader title={translate("import_users.error.error")} /> <CardHeader title={translate("import_users.error.error")} />
<CardContent> <CardContent>
@ -484,7 +545,7 @@ const FilePicker = () => {
</Container> </Container>
); );
const uploadCard = !importResults && ( let uploadCard = !importResults && (
<Container> <Container>
<CardHeader title={translate("import_users.cards.upload.header")} /> <CardHeader title={translate("import_users.cards.upload.header")} />
<CardContent> <CardContent>
@ -492,22 +553,35 @@ const FilePicker = () => {
<a href="./data/example.csv">example.csv</a> <a href="./data/example.csv">example.csv</a>
<br /> <br />
<br /> <br />
<input type="file" onChange={onFileChange} disabled={progress !== null} /> <input
type="file"
onChange={onFileChange}
enabled={(progress !== null).toString()}
/>
</CardContent> </CardContent>
</Container> </Container>
); );
const resultsCard = importResults && ( let resultsCard = importResults && (
<CardContent> <CardContent>
<CardHeader title={translate("import_users.cards.results.header")} /> <CardHeader title={translate("import_users.cards.results.header")} />
<div> <div>
{translate("import_users.cards.results.total", importResults.totalRecordCount)} {translate(
"import_users.cards.results.total",
importResults.totalRecordCount
)}
<br /> <br />
{translate("import_users.cards.results.successful", importResults.succeededRecords.length)} {translate(
"import_users.cards.results.successful",
importResults.succeededRecords.length
)}
<br /> <br />
{importResults.skippedRecords.length {importResults.skippedRecords.length
? [ ? [
translate("import_users.cards.results.skipped", importResults.skippedRecords.length), translate(
"import_users.cards.results.skipped",
importResults.skippedRecords.length
),
<div> <div>
<button onClick={downloadSkippedRecords}> <button onClick={downloadSkippedRecords}>
{translate("import_users.cards.results.download_skipped")} {translate("import_users.cards.results.download_skipped")}
@ -517,22 +591,41 @@ const FilePicker = () => {
] ]
: ""} : ""}
{importResults.erroredRecords.length {importResults.erroredRecords.length
? [translate("import_users.cards.results.skipped", importResults.erroredRecords.length), <br />] ? [
translate(
"import_users.cards.results.skipped",
importResults.erroredRecords.length
),
<br />,
]
: ""} : ""}
<br /> <br />
{importResults.wasDryRun && [translate("import_users.cards.results.simulated_only"), <br />]} {importResults.wasDryRun && [
translate("import_users.cards.results.simulated_only"),
<br />,
]}
</div> </div>
</CardContent> </CardContent>
); );
const startImportCard = let startImportCard =
!values || values.length === 0 || importResults ? undefined : ( !values || values.length === 0 || importResults ? undefined : (
<CardActions> <CardActions>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={dryRun} onChange={onDryRunModeChanged} disabled={progress !== null} />} control={
<Checkbox
checked={dryRun}
onChange={onDryRunModeChanged}
enabled={(progress !== null).toString()}
/>
}
label={translate("import_users.cards.startImport.simulate_only")} label={translate("import_users.cards.startImport.simulate_only")}
/> />
<Button size="large" onClick={runImport} disabled={progress !== null}> <Button
size="large"
onClick={runImport}
enabled={(progress !== null).toString()}
>
{translate("import_users.cards.startImport.run_import")} {translate("import_users.cards.startImport.run_import")}
</Button> </Button>
{progress !== null ? ( {progress !== null ? (
@ -543,7 +636,7 @@ const FilePicker = () => {
</CardActions> </CardActions>
); );
const allCards: JSX.Element[] = []; let allCards = [];
if (uploadCard) allCards.push(uploadCard); if (uploadCard) allCards.push(uploadCard);
if (errorCards) allCards.push(errorCards); if (errorCards) allCards.push(errorCards);
if (conflictCards) allCards.push(conflictCards); if (conflictCards) allCards.push(conflictCards);
@ -551,9 +644,12 @@ const FilePicker = () => {
if (startImportCard) allCards.push(startImportCard); if (startImportCard) allCards.push(startImportCard);
if (resultsCard) allCards.push(resultsCard); if (resultsCard) allCards.push(resultsCard);
const cardContainer = <Card>{allCards}</Card>; let cardContainer = <Card>{allCards}</Card>;
return [<Title defaultTitle={translate("import_users.title")} />, cardContainer]; return [
<Title defaultTitle={translate("import_users.title")} />,
cardContainer,
];
}; };
export const ImportFeature = FilePicker; export const ImportFeature = FilePicker;

View File

@ -1,8 +1,4 @@
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import LockIcon from "@mui/icons-material/Lock";
import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material";
import { styled } from "@mui/material/styles";
import { import {
Form, Form,
FormDataConsumer, FormDataConsumer,
@ -14,25 +10,34 @@ import {
useTranslate, useTranslate,
PasswordInput, PasswordInput,
TextInput, TextInput,
useLocales,
} from "react-admin"; } from "react-admin";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import {
import { useAppContext } from "../AppContext"; Avatar,
Box,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock";
import { import {
getServerVersion, getServerVersion,
getSupportedFeatures,
getSupportedLoginFlows, getSupportedLoginFlows,
getWellKnownUrl, getWellKnownUrl,
isValidBaseUrl, isValidBaseUrl,
splitMxid, splitMxid,
} from "../synapse/synapse"; } from "../synapse/synapse";
import storage from "../storage";
const FormBox = styled(Box)(({ theme }) => ({ const FormBox = styled(Box)(({ theme }) => ({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minHeight: "calc(100vh - 1rem)", minHeight: "calc(100vh - 1em)",
alignItems: "center", alignItems: "center",
justifyContent: "flex-start", justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)", background: "url(./images/floating-cogs.svg)",
@ -41,12 +46,12 @@ const FormBox = styled(Box)(({ theme }) => ({
backgroundSize: "cover", backgroundSize: "cover",
[`& .card`]: { [`& .card`]: {
width: "30rem", minWidth: "30em",
marginTop: "6rem", marginTop: "6em",
marginBottom: "6rem", marginBottom: "6em",
}, },
[`& .avatar`]: { [`& .avatar`]: {
margin: "1rem", margin: "1em",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
}, },
@ -55,47 +60,36 @@ const FormBox = styled(Box)(({ theme }) => ({
}, },
[`& .hint`]: { [`& .hint`]: {
marginTop: "1em", marginTop: "1em",
marginBottom: "1em",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
color: theme.palette.grey[600], color: theme.palette.grey[600],
}, },
[`& .form`]: { [`& .form`]: {
padding: "0 1rem 1rem 1rem", padding: "0 1em 1em 1em",
}, },
[`& .select`]: { [`& .input`]: {
marginBottom: "2rem", marginTop: "1em",
}, },
[`& .actions`]: { [`& .actions`]: {
padding: "0 1rem 1rem 1rem", padding: "0 1em 1em 1em",
}, },
[`& .serverVersion`]: { [`& .serverVersion`]: {
color: theme.palette.grey[500], color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif", fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginLeft: "0.5rem", marginBottom: "1em",
}, marginLeft: "0.5em",
[`& .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 allowSingleBaseUrl = typeof restrictBaseUrl === "string";
const allowMultipleBaseUrls = Array.isArray(restrictBaseUrl);
const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true); const [supportPassAuth, setSupportPassAuth] = useState(true);
const [locale, setLocale] = useLocaleState(); const [locale, setLocale] = useLocaleState();
const locales = useLocales();
const translate = useTranslate(); const translate = useTranslate();
const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url"); const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
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);
@ -103,9 +97,13 @@ const LoginPage = () => {
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
window.history.replaceState({}, "", window.location.href.replace(loginToken[0], "#").split("#")[0]); window.history.replaceState(
const baseUrl = storage.getItem("sso_base_url"); {},
storage.removeItem("sso_base_url"); "",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url");
if (baseUrl) { if (baseUrl) {
const auth = { const auth = {
base_url: baseUrl, base_url: baseUrl,
@ -129,10 +127,26 @@ const LoginPage = () => {
} }
} }
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
);
const validateBaseUrl = value => { const validateBaseUrl = value => {
if (!value.match(/^(http|https):\/\//)) { if (!value.match(/^(http|https):\/\//)) {
return translate("synapseadmin.auth.protocol_error"); return translate("synapseadmin.auth.protocol_error");
} else if (!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)) { } else if (
!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)
) {
return translate("synapseadmin.auth.url_error"); return translate("synapseadmin.auth.url_error");
} else { } else {
return undefined; return undefined;
@ -155,7 +169,7 @@ const LoginPage = () => {
}; };
const handleSSO = () => { const handleSSO = () => {
storage.setItem("sso_base_url", ssoBaseUrl); localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent( const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href window.location.href
)}`; )}`;
@ -165,99 +179,92 @@ const LoginPage = () => {
const UserData = ({ formData }) => { const UserData = ({ formData }) => {
const form = useFormContext(); const form = useFormContext();
const [serverVersion, setServerVersion] = useState(""); const [serverVersion, setServerVersion] = useState("");
const [matrixVersions, setMatrixVersions] = useState("");
const handleUsernameChange = () => { const handleUsernameChange = _ => {
if (formData.base_url || allowSingleBaseUrl) return; if (formData.base_url || cfg_base_url) 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) {
getWellKnownUrl(domain).then(url => { getWellKnownUrl(domain).then(url => form.setValue("base_url", url));
if (allowAnyBaseUrl || (allowMultipleBaseUrls && restrictBaseUrl.includes(url)))
form.setValue("base_url", url);
});
} }
}; };
useEffect(() => { useEffect(() => {
if (formData.base_url === "" && allowMultipleBaseUrls) {
form.setValue("base_url", restrictBaseUrl[0]);
}
if (!isValidBaseUrl(formData.base_url)) return; if (!isValidBaseUrl(formData.base_url)) return;
getServerVersion(formData.base_url) getServerVersion(formData.base_url)
.then(serverVersion => setServerVersion(`${translate("synapseadmin.auth.server_version")} ${serverVersion}`)) .then(serverVersion =>
.catch(() => setServerVersion("")); setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${serverVersion}`
getSupportedFeatures(formData.base_url) )
.then(features =>
setMatrixVersions(`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`)
) )
.catch(() => setMatrixVersions("")); .catch(() => setServerVersion(""));
// Set SSO Url // Set SSO Url
getSupportedLoginFlows(formData.base_url) getSupportedLoginFlows(formData.base_url)
.then(loginFlows => { .then(loginFlows => {
const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined; const supportPass =
const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined; loginFlows.find(f => f.type === "m.login.password") !== undefined;
const supportSSO =
loginFlows.find(f => f.type === "m.login.sso") !== undefined;
setSupportPassAuth(supportPass); setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? formData.base_url : ""); setSSOBaseUrl(supportSSO ? formData.base_url : "");
}) })
.catch(() => setSSOBaseUrl("")); .catch(() => setSSOBaseUrl(""));
}, [formData.base_url, form]); }, [formData.base_url]);
return ( return (
<> <>
<Box> <Box>
<TextInput <TextInput
autoFocus autoFocus
source="username" name="username"
component={renderInput}
label="ra.auth.username" label="ra.auth.username"
autoComplete="username"
disabled={loading || !supportPassAuth} disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange} onBlur={handleUsernameChange}
resettable resettable
fullWidth
className="input"
validate={required()} validate={required()}
/> />
</Box> </Box>
<Box> <Box>
<PasswordInput <PasswordInput
source="password" name="password"
component={renderInput}
label="ra.auth.password" label="ra.auth.password"
type="password" type="password"
autoComplete="current-password"
disabled={loading || !supportPassAuth} disabled={loading || !supportPassAuth}
resettable resettable
fullWidth
className="input"
validate={required()} validate={required()}
/> />
</Box> </Box>
<Box> <Box>
<TextInput <TextInput
source="base_url" name="base_url"
component={renderInput}
label="synapseadmin.auth.base_url" label="synapseadmin.auth.base_url"
select={allowMultipleBaseUrls} disabled={cfg_base_url || loading}
autoComplete="url" resettable
disabled={loading} fullWidth
readOnly={allowSingleBaseUrl} className="input"
resettable={allowAnyBaseUrl}
validate={[required(), validateBaseUrl]} validate={[required(), validateBaseUrl]}
> />
{allowMultipleBaseUrls &&
restrictBaseUrl.map(url => (
<MenuItem key={url} value={url}>
{url}
</MenuItem>
))}
</TextInput>
</Box> </Box>
<Typography className="serverVersion">{serverVersion}</Typography> <Typography className="serverVersion">{serverVersion}</Typography>
<Typography className="matrixVersions">{matrixVersions}</Typography>
</> </>
); );
}; };
return ( return (
<Form defaultValues={{ base_url: base_url }} onSubmit={handleSubmit} mode="onTouched"> <Form
defaultValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
mode="onTouched"
>
<FormBox> <FormBox>
<Card className="card"> <Card className="card">
<Box className="avatar"> <Box className="avatar">
@ -273,18 +280,23 @@ const LoginPage = () => {
<Box className="form"> <Box className="form">
<Select <Select
value={locale} value={locale}
onChange={e => setLocale(e.target.value)} onChange={e => {
setLocale(e.target.value);
}}
fullWidth fullWidth
disabled={loading} disabled={loading}
className="select" className="input"
> >
{locales.map(l => ( <MenuItem value="de">Deutsch</MenuItem>
<MenuItem key={l.locale} value={l.locale}> <MenuItem value="en">English</MenuItem>
{l.name} <MenuItem value="fr">Français</MenuItem>
</MenuItem> <MenuItem value="it">Italiano</MenuItem>
))} <MenuItem value="zh">简体中文</MenuItem>
<MenuItem value="fa">Persian(فارسی)</MenuItem>
</Select> </Select>
<FormDataConsumer>{formDataProps => <UserData {...formDataProps} />}</FormDataConsumer> <FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
<CardActions className="actions"> <CardActions className="actions">
<Button <Button
variant="contained" variant="contained"

View File

@ -0,0 +1,14 @@
import React from "react";
import { render } from "@testing-library/react";
import { AdminContext } from "react-admin";
import LoginPage from "./LoginPage";
describe("LoginForm", () => {
it("renders", () => {
render(
<AdminContext>
<LoginPage />
</AdminContext>
);
});
});

View File

@ -1,37 +1,62 @@
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber"; import React from "react";
import { import {
BooleanInput, BooleanInput,
Create, Create,
CreateProps,
Datagrid, Datagrid,
DateField, DateField,
DateTimeInput, DateTimeInput,
Edit, Edit,
EditProps,
List, List,
ListProps,
maxValue, maxValue,
number, number,
NumberField, NumberField,
NumberInput, NumberInput,
regex, regex,
ResourceProps,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
TextInput, TextInput,
TextField, TextField,
Toolbar, Toolbar,
} from "react-admin"; } from "react-admin";
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
import { DATE_FORMAT, dateFormatter, dateParser } from "../components/date"; const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)]; const validateToken = [regex(/^[A-Za-z0-9._~-]{0,64}$/)];
const validateUsesAllowed = [number()]; const validateUsesAllowed = [number()];
const validateLength = [number(), maxValue(64)]; const validateLength = [number(), maxValue(64)];
const dateParser = v => {
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const dateFormatter = v => {
if (v === undefined || v === null) return;
const d = new Date(v);
const pad = "00";
const year = d.getFullYear().toString();
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
const day = (pad + d.getDate().toString()).slice(-2);
const hour = (pad + d.getHours().toString()).slice(-2);
const minute = (pad + d.getMinutes().toString()).slice(-2);
// target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`;
};
const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />]; const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
export const RegistrationTokenList = (props: ListProps) => ( export const RegistrationTokenList = props => (
<List <List
{...props} {...props}
filters={registrationTokenFilters} filters={registrationTokenFilters}
@ -44,12 +69,17 @@ export const RegistrationTokenList = (props: ListProps) => (
<NumberField source="uses_allowed" sortable={false} /> <NumberField source="uses_allowed" sortable={false} />
<NumberField source="pending" sortable={false} /> <NumberField source="pending" sortable={false} />
<NumberField source="completed" sortable={false} /> <NumberField source="completed" sortable={false} />
<DateField source="expiry_time" showTime options={DATE_FORMAT} sortable={false} /> <DateField
source="expiry_time"
showTime
options={date_format}
sortable={false}
/>
</Datagrid> </Datagrid>
</List> </List>
); );
export const RegistrationTokenCreate = (props: CreateProps) => ( export const RegistrationTokenCreate = props => (
<Create {...props} redirect="list"> <Create {...props} redirect="list">
<SimpleForm <SimpleForm
toolbar={ toolbar={
@ -59,32 +89,49 @@ export const RegistrationTokenCreate = (props: CreateProps) => (
</Toolbar> </Toolbar>
} }
> >
<TextInput source="token" autoComplete="off" validate={validateToken} resettable /> <TextInput
source="token"
autoComplete="off"
validate={validateToken}
resettable
/>
<NumberInput <NumberInput
source="length" source="length"
validate={validateLength} validate={validateLength}
helperText="resources.registration_tokens.helper.length" helperText="resources.registration_tokens.helper.length"
step={1} step={1}
/> />
<NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} /> <NumberInput
source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput source="expiry_time" parse={dateParser} /> <DateTimeInput source="expiry_time" parse={dateParser} />
</SimpleForm> </SimpleForm>
</Create> </Create>
); );
export const RegistrationTokenEdit = (props: EditProps) => ( export const RegistrationTokenEdit = props => (
<Edit {...props}> <Edit {...props}>
<SimpleForm> <SimpleForm>
<TextInput source="token" disabled /> <TextInput source="token" disabled />
<NumberInput source="pending" disabled /> <NumberInput source="pending" disabled />
<NumberInput source="completed" disabled /> <NumberInput source="completed" disabled />
<NumberInput source="uses_allowed" validate={validateUsesAllowed} step={1} /> <NumberInput
<DateTimeInput source="expiry_time" parse={dateParser} format={dateFormatter} /> source="uses_allowed"
validate={validateUsesAllowed}
step={1}
/>
<DateTimeInput
source="expiry_time"
parse={dateParser}
format={dateFormatter}
/>
</SimpleForm> </SimpleForm>
</Edit> </Edit>
); );
const resource: ResourceProps = { const resource = {
name: "registration_tokens", name: "registration_tokens",
icon: RegistrationTokenIcon, icon: RegistrationTokenIcon,
list: RegistrationTokenList, list: RegistrationTokenList,

View File

@ -1,18 +1,14 @@
import RoomDirectoryIcon from "@mui/icons-material/FolderShared"; import React from "react";
import { import {
BooleanField, BooleanField,
BulkDeleteButton, BulkDeleteButton,
BulkDeleteButtonProps,
Button, Button,
ButtonProps,
DatagridConfigurable, DatagridConfigurable,
DeleteButtonProps,
ExportButton, ExportButton,
DeleteButton, DeleteButton,
List, List,
NumberField, NumberField,
Pagination, Pagination,
ResourceProps,
SelectColumnsButton, SelectColumnsButton,
TextField, TextField,
TopToolbar, TopToolbar,
@ -25,13 +21,15 @@ import {
useRefresh, useRefresh,
useUnselectAll, useUnselectAll,
} from "react-admin"; } from "react-admin";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "react-query";
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import AvatarField from "./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 => {
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
@ -52,7 +50,7 @@ export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
); );
}; };
export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) => ( export const RoomDirectoryBulkUnpublishButton = props => (
<BulkDeleteButton <BulkDeleteButton
{...props} {...props}
label="resources.room_directory.action.erase" label="resources.room_directory.action.erase"
@ -64,46 +62,49 @@ export const RoomDirectoryBulkUnpublishButton = (props: BulkDeleteButtonProps) =
/> />
); );
export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => { export const RoomDirectoryBulkPublishButton = props => {
const { selectedIds } = useListContext(); const { selectedIds } = useListContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms"); const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider(); const dataProvider = useDataProvider();
const { mutate, isPending } = useMutation({ const { mutate, isLoading } = useMutation(
mutationFn: () => () =>
dataProvider.createMany("room_directory", { dataProvider.createMany("room_directory", {
ids: selectedIds, ids: selectedIds,
data: {}, data: {},
}), }),
onSuccess: () => { {
notify("resources.room_directory.action.send_success"); onSuccess: () => {
unselectAllRooms(); notify("resources.room_directory.action.send_success");
refresh(); unselectAllRooms();
}, refresh();
onError: () => },
notify("resources.room_directory.action.send_failure", { onError: () =>
type: "error", notify("resources.room_directory.action.send_failure", {
}), type: "error",
}); }),
}
);
return ( return (
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}> <Button
{...props}
label="resources.room_directory.action.create"
onClick={mutate}
disabled={isLoading}
>
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
}; };
export const RoomDirectoryPublishButton = (props: ButtonProps) => { export const RoomDirectoryPublishButton = props => {
const record = useRecordContext(); const record = useRecordContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
const [create, { isLoading }] = useCreate(); const [create, { isLoading }] = useCreate();
if (!record) {
return;
}
const handleSend = () => { const handleSend = () => {
create( create(
"room_directory", "room_directory",
@ -122,7 +123,12 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
}; };
return ( return (
<Button {...props} label="resources.room_directory.action.create" onClick={handleSend} disabled={isLoading}> <Button
{...props}
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={isLoading}
>
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
@ -136,9 +142,13 @@ const RoomDirectoryListActions = () => (
); );
export const RoomDirectoryList = () => ( export const RoomDirectoryList = () => (
<List pagination={<RoomDirectoryPagination />} perPage={100} actions={<RoomDirectoryListActions />}> <List
pagination={<RoomDirectoryPagination />}
perPage={100}
actions={<RoomDirectoryListActions />}
>
<DatagridConfigurable <DatagridConfigurable
rowClick={id => "/rooms/" + id + "/show"} rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />} bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]} omit={["room_id", "canonical_alias", "topic"]}
> >
@ -148,18 +158,46 @@ export const RoomDirectoryList = () => (
sx={{ height: "40px", width: "40px" }} sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar" label="resources.rooms.fields.avatar"
/> />
<TextField source="name" sortable={false} label="resources.rooms.fields.name" /> <TextField
<TextField source="room_id" sortable={false} label="resources.rooms.fields.room_id" /> source="name"
<TextField source="canonical_alias" sortable={false} label="resources.rooms.fields.canonical_alias" /> sortable={false}
<TextField source="topic" sortable={false} label="resources.rooms.fields.topic" /> label="resources.rooms.fields.name"
<NumberField source="num_joined_members" sortable={false} label="resources.rooms.fields.joined_members" /> />
<BooleanField source="world_readable" sortable={false} label="resources.room_directory.fields.world_readable" /> <TextField
<BooleanField source="guest_can_join" sortable={false} label="resources.room_directory.fields.guest_can_join" /> source="room_id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<TextField
source="canonical_alias"
sortable={false}
label="resources.rooms.fields.canonical_alias"
/>
<TextField
source="topic"
sortable={false}
label="resources.rooms.fields.topic"
/>
<NumberField
source="num_joined_members"
sortable={false}
label="resources.rooms.fields.joined_members"
/>
<BooleanField
source="world_readable"
sortable={false}
label="resources.room_directory.fields.world_readable"
/>
<BooleanField
source="guest_can_join"
sortable={false}
label="resources.room_directory.fields.guest_can_join"
/>
</DatagridConfigurable> </DatagridConfigurable>
</List> </List>
); );
const resource: ResourceProps = { const resource = {
name: "room_directory", name: "room_directory",
icon: RoomDirectoryIcon, icon: RoomDirectoryIcon,
list: RoomDirectoryList, list: RoomDirectoryList,

View File

@ -1,16 +1,10 @@
import { useState } from "react"; import React, { useState } from "react";
import IconCancel from "@mui/icons-material/Cancel";
import MessageIcon from "@mui/icons-material/Message";
import { Dialog, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { import {
Button, Button,
RaRecord,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
TextInput, TextInput,
Toolbar, Toolbar,
ToolbarProps,
required, required,
useCreate, useCreate,
useDataProvider, useDataProvider,
@ -20,14 +14,25 @@ import {
useTranslate, useTranslate,
useUnselectAll, useUnselectAll,
} from "react-admin"; } from "react-admin";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "react-query";
import MessageIcon from "@mui/icons-material/Message";
import IconCancel from "@mui/icons-material/Cancel";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => { const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const ServerNoticeToolbar = (props: ToolbarProps & { pristine?: boolean }) => ( const ServerNoticeToolbar = props => (
<Toolbar {...props}> <Toolbar {...props}>
<SaveButton label="resources.servernotices.action.send" disabled={props.pristine} /> <SaveButton
label="resources.servernotices.action.send"
disabled={props.pristine}
/>
<Button label="ra.action.cancel" onClick={onClose}> <Button label="ra.action.cancel" onClick={onClose}>
<IconCancel /> <IconCancel />
</Button> </Button>
@ -35,14 +40,19 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
); );
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose} loading={loading}>
<DialogTitle>{translate("resources.servernotices.action.send")}</DialogTitle> <DialogTitle>
{translate("resources.servernotices.action.send")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>{translate("resources.servernotices.helper.send")}</DialogContentText> <DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}> <SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
<TextInput <TextInput
source="body" source="body"
label="resources.servernotices.fields.body" label="resources.servernotices.fields.body"
fullWidth
multiline multiline
rows="4" rows="4"
resettable resettable
@ -58,16 +68,12 @@ export const ServerNoticeButton = () => {
const record = useRecordContext(); const record = useRecordContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const notify = useNotify(); const notify = useNotify();
const [create, { isLoading }] = useCreate(); const [create, { isloading }] = useCreate();
const handleDialogOpen = () => setOpen(true); const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false); const handleDialogClose = () => setOpen(false);
if (!record) { const handleSend = values => {
return;
}
const handleSend = (values: Partial<RaRecord>) => {
create( create(
"servernotices", "servernotices",
{ data: { id: record.id, ...values } }, { data: { id: record.id, ...values } },
@ -86,10 +92,18 @@ export const ServerNoticeButton = () => {
return ( return (
<> <>
<Button label="resources.servernotices.send" onClick={handleDialogOpen} disabled={isLoading}> <Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={isloading}
>
<MessageIcon /> <MessageIcon />
</Button> </Button>
<ServerNoticeDialog open={open} onClose={handleDialogClose} onSubmit={handleSend} /> <ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSubmit={handleSend}
/>
</> </>
); );
}; };
@ -103,29 +117,39 @@ export const ServerNoticeBulkButton = () => {
const unselectAllUsers = useUnselectAll("users"); const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider(); const dataProvider = useDataProvider();
const { mutate: sendNotices, isPending } = useMutation({ const { mutate: sendNotices, isLoading } = useMutation(
mutationFn: (data) => data =>
dataProvider.createMany("servernotices", { dataProvider.createMany("servernotices", {
ids: selectedIds, ids: selectedIds,
data: data, data: data,
}), }),
onSuccess: () => { {
notify("resources.servernotices.action.send_success"); onSuccess: () => {
unselectAllUsers(); notify("resources.servernotices.action.send_success");
closeDialog(); unselectAllUsers();
}, closeDialog();
onError: () => },
notify("resources.servernotices.action.send_failure", { onError: () =>
type: "error", notify("resources.servernotices.action.send_failure", {
}), type: "error",
}); }),
}
);
return ( return (
<> <>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}> <Button
label="resources.servernotices.send"
onClick={openDialog}
disabled={isLoading}
>
<MessageIcon /> <MessageIcon />
</Button> </Button>
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} /> <ServerNoticeDialog
open={open}
onClose={closeDialog}
onSubmit={sendNotices}
/>
</> </>
); );
}; };

View File

@ -1,28 +0,0 @@
export const DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
export const dateParser = (v: string | number | Date): number => {
const d = new Date(v);
return d.getTime();
};
export const dateFormatter = (v: string | number | Date | undefined | null): string => {
if (v === undefined || v === null) return "";
const d = new Date(v);
const pad = "00";
const year = d.getFullYear().toString();
const month = (pad + (d.getMonth() + 1).toString()).slice(-2);
const day = (pad + d.getDate().toString()).slice(-2);
const hour = (pad + d.getHours().toString()).slice(-2);
const minute = (pad + d.getMinutes().toString()).slice(-2);
// target format yyyy-MM-ddThh:mm
return `${year}-${month}-${day}T${hour}:${minute}`;
};

View File

@ -1,25 +1,14 @@
import { get } from "lodash"; import React from "react";
import { MouseEvent } from "react";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
import { blue } from "@mui/material/colors";
import { import {
Button, Button,
Datagrid, Datagrid,
DateField, DateField,
List, List,
ListProps,
Pagination, Pagination,
RaRecord,
ReferenceField, ReferenceField,
ReferenceManyField, ReferenceManyField,
ResourceProps,
SearchInput, SearchInput,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -29,13 +18,28 @@ import {
useNotify, useNotify,
useRefresh, useRefresh,
useTranslate, useTranslate,
DateFieldProps,
} from "react-admin"; } from "react-admin";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
import { DATE_FORMAT } from "../components/date"; const DestinationPagination = () => (
import { lighten, useTheme } from '@mui/material'; <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const DestinationPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const destinationRowSx = (record, _index) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
});
const destinationFilters = [<SearchInput source="destination" alwaysOn />]; const destinationFilters = [<SearchInput source="destination" alwaysOn />];
@ -48,7 +52,7 @@ export const DestinationReconnectButton = () => {
// Reconnect is not required if no error has occurred. (`failure_ts`) // Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null; if (!record || !record.failure_ts) return null;
const handleClick = (e: MouseEvent<HTMLButtonElement>) => { const handleClick = e => {
// Prevents redirection to the detail page when clicking in the list // Prevents redirection to the detail page when clicking in the list
e.stopPropagation(); e.stopPropagation();
@ -70,7 +74,11 @@ export const DestinationReconnectButton = () => {
}; };
return ( return (
<Button label="resources.destinations.action.reconnect" onClick={handleClick} disabled={isLoading}> <Button
label="resources.destinations.action.reconnect"
onClick={handleClick}
disabled={isLoading}
>
<AutorenewIcon /> <AutorenewIcon />
</Button> </Button>
); );
@ -87,30 +95,12 @@ const DestinationTitle = () => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<span> <span>
{translate("resources.destinations.name", 1)} {record?.destination} {translate("resources.destinations.name", 1)} {record.destination}
</span> </span>
); );
}; };
const RetryDateField = (props: DateFieldProps) => { export const DestinationList = props => {
const record = useRecordContext(props);
if (props.source && get(record, props.source) === 0) {
return <DateField {...props} record={{ ...record, [props.source]: null }} />;
}
return <DateField {...props} />;
};
export const DestinationList = (props: ListProps) => {
const { palette: { error, mode }, } = useTheme();
const destinationRowSx = (record: RaRecord) => ({
backgroundColor: record.retry_last_ts > 0 ? lighten(error[mode], 0.5) : undefined,
"& > td": mode === 'dark' ? {
color: record.retry_last_ts > 0 ? "black" : "white",
"& > button": {
color: blue[700],
},
} : undefined,
});
return ( return (
<List <List
{...props} {...props}
@ -118,10 +108,14 @@ 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
rowSx={destinationRowSx}
rowClick={(id, _resource, _record) => `${id}/show/rooms`}
bulkActionButtons={false}
>
<TextField source="destination" /> <TextField source="destination" />
<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} /> <DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" /> <TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" /> <TextField source="last_successful_stream_ordering" />
<DestinationReconnectButton /> <DestinationReconnectButton />
@ -130,29 +124,43 @@ export const DestinationList = (props: ListProps) => {
); );
}; };
export const DestinationShow = (props: ShowProps) => { export const DestinationShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show actions={<DestinationShowActions />} title={<DestinationTitle />} {...props}> <Show
actions={<DestinationShowActions />}
title={<DestinationTitle />}
{...props}
>
<TabbedShowLayout> <TabbedShowLayout>
<Tab label="status" icon={<ViewListIcon />}> <Tab label="status" icon={<ViewListIcon />}>
<TextField source="destination" /> <TextField source="destination" />
<DateField source="failure_ts" showTime options={DATE_FORMAT} /> <DateField source="failure_ts" showTime options={date_format} />
<DateField source="retry_last_ts" showTime options={DATE_FORMAT} /> <DateField source="retry_last_ts" showTime options={date_format} />
<TextField source="retry_interval" /> <TextField source="retry_interval" />
<TextField source="last_successful_stream_ordering" /> <TextField source="last_successful_stream_ordering" />
</Tab> </Tab>
<Tab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<FolderSharedIcon />} path="rooms"> <Tab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<FolderSharedIcon />}
path="rooms"
>
<ReferenceManyField <ReferenceManyField
reference="destination_rooms" reference="destination_rooms"
target="destination" target="destination"
label={false} addLabel={false}
pagination={<DestinationPagination />} pagination={<DestinationPagination />}
perPage={50} perPage={50}
> >
<Datagrid style={{ width: "100%" }} rowClick={id => `/rooms/${id}/show`}> <Datagrid
<TextField source="room_id" label="resources.rooms.fields.room_id" /> style={{ width: "100%" }}
rowClick={(id, resource, record) => `/rooms/${id}/show`}
>
<TextField
source="room_id"
label="resources.rooms.fields.room_id"
/>
<TextField source="stream_ordering" sortable={false} /> <TextField source="stream_ordering" sortable={false} />
<ReferenceField <ReferenceField
label="resources.rooms.fields.name" label="resources.rooms.fields.name"
@ -171,7 +179,7 @@ export const DestinationShow = (props: ShowProps) => {
); );
}; };
const resource: ResourceProps = { const resource = {
name: "destinations", name: "destinations",
icon: DestinationsIcon, icon: DestinationsIcon,
list: DestinationList, list: DestinationList,

View File

@ -0,0 +1,51 @@
import React from "react";
import {
DeleteButton,
useDelete,
useNotify,
useRecordContext,
useRefresh,
} from "react-admin";
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice] = useDelete();
if (!record) return null;
const handleConfirm = () => {
removeDevice(
"devices",
// needs previousData for user_id
{ id: record.id, previousData: record },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onError: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
};
return (
<DeleteButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
onConfirm={handleConfirm}
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

View File

@ -1,21 +0,0 @@
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
const record = useRecordContext();
if (!record) return null;
return (
<DeleteWithConfirmButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

View File

@ -1,47 +1,49 @@
import { get } from "lodash"; import React, { useState } from "react";
import { useState } from "react";
import BlockIcon from "@mui/icons-material/Block";
import IconCancel from "@mui/icons-material/Cancel";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import { import {
BooleanInput, BooleanInput,
Button, Button,
ButtonProps,
DateTimeInput, DateTimeInput,
NumberInput, NumberInput,
SaveButton, SaveButton,
SimpleForm, SimpleForm,
Toolbar, Toolbar,
ToolbarProps,
useCreate, useCreate,
useDataProvider,
useDelete, useDelete,
useNotify, useNotify,
useRecordContext, useRecordContext,
useRefresh, useRefresh,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { useMutation } from "@tanstack/react-query"; import BlockIcon from "@mui/icons-material/Block";
import { Link } from "react-router-dom"; import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import {
Dialog,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip,
} from "@mui/material";
import IconCancel from "@mui/icons-material/Cancel";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { alpha, useTheme } from "@mui/material/styles";
import { dateParser } from "./date"; const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage";
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const DeleteMediaToolbar = (props: ToolbarProps) => ( const dateParser = v => {
const d = new Date(v);
if (isNaN(d)) return 0;
return d.getTime();
};
const DeleteMediaToolbar = props => (
<Toolbar {...props}> <Toolbar {...props}>
<SaveButton label="delete_media.action.send" icon={<DeleteSweepIcon />} /> <SaveButton
label="resources.delete_media.action.send"
icon={<DeleteSweepIcon />}
/>
<Button label="ra.action.cancel" onClick={onClose}> <Button label="ra.action.cancel" onClick={onClose}>
<IconCancel /> <IconCancel />
</Button> </Button>
@ -49,27 +51,34 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
); );
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose} loading={loading}>
<DialogTitle>{translate("delete_media.action.send")}</DialogTitle> <DialogTitle>
{translate("resources.delete_media.action.send")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText> <DialogContentText>
{translate("resources.delete_media.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}> <SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput <DateTimeInput
fullWidth
source="before_ts" source="before_ts"
label="delete_media.fields.before_ts" label="resources.delete_media.fields.before_ts"
defaultValue={0} defaultValue={0}
parse={dateParser} parse={dateParser}
/> />
<NumberInput <NumberInput
fullWidth
source="size_gt" source="size_gt"
label="delete_media.fields.size_gt" label="resources.delete_media.fields.size_gt"
defaultValue={0} defaultValue={0}
min={0} min={0}
step={1024} step={1024}
/> />
<BooleanInput <BooleanInput
fullWidth
source="keep_profiles" source="keep_profiles"
label="delete_media.fields.keep_profiles" label="resources.delete_media.fields.keep_profiles"
defaultValue={true} defaultValue={true}
/> />
</SimpleForm> </SimpleForm>
@ -78,34 +87,40 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
); );
}; };
export const DeleteMediaButton = (props: ButtonProps) => { export const DeleteMediaButton = props => {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const notify = useNotify(); const notify = useNotify();
const dataProvider = useDataProvider<SynapseDataProvider>(); const [deleteOne, { isLoading }] = useDelete();
const { mutate: deleteMedia, isPending } = useMutation({
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
});
const openDialog = () => setOpen(true); const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false); const closeDialog = () => setOpen(false);
const deleteMedia = values => {
deleteOne(
"delete_media",
// needs meta.before_ts, meta.size_gt and meta.keep_profiles
{ meta: values },
{
onSuccess: () => {
notify("resources.delete_media.action.send_success");
closeDialog();
},
onError: () =>
notify("resources.delete_media.action.send_failure", {
type: "error",
}),
}
);
};
return ( return (
<> <>
<Button <Button
{...props} {...props}
label="delete_media.action.send" label="resources.delete_media.action.send"
onClick={openDialog} onClick={openDialog}
disabled={isPending} disabled={isLoading}
sx={{ sx={{
color: theme.palette.error.main, color: theme.palette.error.main,
"&:hover": { "&:hover": {
@ -119,12 +134,16 @@ export const DeleteMediaButton = (props: ButtonProps) => {
> >
<DeleteSweepIcon /> <DeleteSweepIcon />
</Button> </Button>
<DeleteMediaDialog open={open} onClose={closeDialog} onSubmit={deleteMedia} /> <DeleteMediaDialog
open={open}
onClose={closeDialog}
onSubmit={deleteMedia}
/>
</> </>
); );
}; };
export const ProtectMediaButton = (props: ButtonProps) => { export const ProtectMediaButton = () => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
const refresh = useRefresh(); const refresh = useRefresh();
@ -185,7 +204,7 @@ export const ProtectMediaButton = (props: ButtonProps) => {
Button instead BooleanField for Button instead BooleanField for
consistent appearance and position in the column consistent appearance and position in the column
*/} */}
<Button {...props} disabled={true}> <Button disabled={true}>
<ClearIcon /> <ClearIcon />
</Button> </Button>
</div> </div>
@ -199,7 +218,7 @@ export const ProtectMediaButton = (props: ButtonProps) => {
arrow arrow
> >
<div> <div>
<Button {...props} onClick={handleUnprotect} disabled={isLoading}> <Button onClick={handleUnprotect} disabled={isLoading}>
<LockIcon /> <LockIcon />
</Button> </Button>
</div> </div>
@ -212,7 +231,7 @@ export const ProtectMediaButton = (props: ButtonProps) => {
})} })}
> >
<div> <div>
<Button {...props} onClick={handleProtect} disabled={isLoading}> <Button onClick={handleProtect} disabled={isLoading}>
<LockOpenIcon /> <LockOpenIcon />
</Button> </Button>
</div> </div>
@ -222,7 +241,7 @@ export const ProtectMediaButton = (props: ButtonProps) => {
); );
}; };
export const QuarantineMediaButton = (props: ButtonProps) => { export const QuarantineMediaButton = props => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
const refresh = useRefresh(); const refresh = useRefresh();
@ -288,7 +307,11 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
})} })}
> >
<div> <div>
<Button {...props} onClick={handleRemoveQuarantaine} disabled={isLoading}> <Button
{...props}
onClick={handleRemoveQuarantaine}
disabled={isLoading}
>
<BlockIcon color="error" /> <BlockIcon color="error" />
</Button> </Button>
</div> </div>
@ -301,7 +324,7 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
})} })}
> >
<div> <div>
<Button {...props} onClick={handleQuarantaine} disabled={isLoading}> <Button onClick={handleQuarantaine} disabled={isLoading}>
<BlockIcon /> <BlockIcon />
</Button> </Button>
</div> </div>
@ -310,49 +333,3 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
</> </>
); );
}; };
export const ViewMediaButton = ({ media_id, label }) => {
const translate = useTranslate();
const url = getMediaUrl(media_id);
return (
<Box style={{ whiteSpace: "pre" }}>
<Tooltip title={translate("resources.users_media.action.open")}>
<span>
<Button
component={Link}
to={url}
target="_blank"
rel="noopener"
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }}
>
<FileOpenIcon />
</Button>
</span>
</Tooltip>
{label}
</Box>
);
};
export const MediaIDField = ({ source }) => {
const homeserver = storage.getItem("home_server");
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString();
if (!src) return null;
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />;
};
export const MXCField = ({ source }) => {
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString();
if (!src) return null;
const media_id = src.replace("mxc://", "");
return <ViewMediaButton media_id={media_id} label={src} />;
};

View File

@ -1,14 +1,4 @@
import EventIcon from "@mui/icons-material/Event"; import React from "react";
import FastForwardIcon from "@mui/icons-material/FastForward";
import UserIcon from "@mui/icons-material/Group";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
import PageviewIcon from "@mui/icons-material/Pageview";
import ViewListIcon from "@mui/icons-material/ViewList";
import RoomIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { import {
BooleanField, BooleanField,
BulkDeleteButton, BulkDeleteButton,
@ -19,17 +9,14 @@ import {
ExportButton, ExportButton,
FunctionField, FunctionField,
List, List,
ListProps,
NumberField, NumberField,
Pagination, Pagination,
ReferenceField, ReferenceField,
ReferenceManyField, ReferenceManyField,
ResourceProps,
SearchInput, SearchInput,
SelectColumnsButton, SelectColumnsButton,
SelectField, SelectField,
Show, Show,
ShowProps,
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
@ -37,21 +24,41 @@ import {
useRecordContext, useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import FastForwardIcon from "@mui/icons-material/FastForward";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
import PageviewIcon from "@mui/icons-material/Pageview";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EventIcon from "@mui/icons-material/Event";
import RoomIcon from "@mui/icons-material/ViewList";
import { import {
RoomDirectoryBulkUnpublishButton, RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkPublishButton, RoomDirectoryBulkPublishButton,
RoomDirectoryUnpublishButton, RoomDirectoryUnpublishButton,
RoomDirectoryPublishButton, RoomDirectoryPublishButton,
} from "./room_directory"; } from "./RoomDirectory";
import { DATE_FORMAT } from "../components/date";
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const RoomPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const RoomTitle = () => { const RoomTitle = () => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
let name = ""; var name = "";
if (record) { if (record) {
name = record.name !== "" ? record.name : record.id; name = record.name !== "" ? record.name : record.id;
} }
@ -65,11 +72,15 @@ const RoomTitle = () => {
const RoomShowActions = () => { const RoomShowActions = () => {
const record = useRecordContext(); const record = useRecordContext();
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />; var roomDirectoryStatus = "";
// FIXME: refresh after (un)publish if (record) {
roomDirectoryStatus = record.public;
}
return ( return (
<TopToolbar> <TopToolbar>
{publishButton} {roomDirectoryStatus === false && <RoomDirectoryPublishButton />}
{roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
<DeleteButton <DeleteButton
mutationMode="pessimistic" mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title" confirmTitle="resources.rooms.action.erase.title"
@ -79,7 +90,7 @@ const RoomShowActions = () => {
); );
}; };
export const RoomShow = (props: ShowProps) => { export const RoomShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}> <Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
@ -94,19 +105,42 @@ export const RoomShow = (props: ShowProps) => {
</ReferenceField> </ReferenceField>
</Tab> </Tab>
<Tab label="synapseadmin.rooms.tabs.detail" icon={<PageviewIcon />} path="detail"> <Tab
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" /> <TextField source="joined_members" />
<TextField source="joined_local_members" /> <TextField source="joined_local_members" />
<TextField source="joined_local_devices" /> <TextField source="joined_local_devices" />
<TextField source="state_events" /> <TextField source="state_events" />
<TextField source="version" /> <TextField source="version" />
<TextField source="encryption" emptyText={translate("resources.rooms.enums.unencrypted")} /> <TextField
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab> </Tab>
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />} path="members"> <Tab
<ReferenceManyField reference="room_members" target="room_id" label={false}> label="synapseadmin.rooms.tabs.members"
<Datagrid style={{ width: "100%" }} rowClick={id => "/users/" + id} bulkActionButtons={false}> icon={<UserIcon />}
<TextField source="id" sortable={false} label="resources.users.fields.id" /> path="members"
>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/users/" + id}
bulkActionButtons={false}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField <ReferenceField
label="resources.users.fields.displayname" label="resources.users.fields.displayname"
source="id" source="id"
@ -120,7 +154,11 @@ export const RoomShow = (props: ShowProps) => {
</ReferenceManyField> </ReferenceManyField>
</Tab> </Tab>
<Tab label="synapseadmin.rooms.tabs.permission" icon={<VisibilityIcon />} path="permission"> <Tab
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" /> <BooleanField source="federatable" />
<BooleanField source="public" /> <BooleanField source="public" />
<SelectField <SelectField
@ -171,20 +209,41 @@ export const RoomShow = (props: ShowProps) => {
/> />
</Tab> </Tab>
<Tab label={translate("resources.room_state.name", { smart_count: 2 })} icon={<EventIcon />} path="state"> <Tab
<ReferenceManyField reference="room_state" target="room_id" label={false}> label={translate("resources.room_state.name", { smart_count: 2 })}
icon={<EventIcon />}
path="state"
>
<ReferenceManyField
reference="room_state"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="type" sortable={false} /> <TextField source="type" sortable={false} />
<DateField source="origin_server_ts" showTime options={DATE_FORMAT} sortable={false} /> <DateField
source="origin_server_ts"
showTime
options={date_format}
sortable={false}
/>
<TextField source="content" sortable={false} /> <TextField source="content" sortable={false} />
<ReferenceField source="sender" reference="users" sortable={false}> <ReferenceField
source="sender"
reference="users"
sortable={false}
>
<TextField source="id" /> <TextField source="id" />
</ReferenceField> </ReferenceField>
</Datagrid> </Datagrid>
</ReferenceManyField> </ReferenceManyField>
</Tab> </Tab>
<Tab label="resources.forward_extremities.name" icon={<FastForwardIcon />} path="forward_extremities"> <Tab
label="resources.forward_extremities.name"
icon={<FastForwardIcon />}
path="forward_extremities"
>
<Box <Box
sx={{ sx={{
fontFamily: "Roboto, Helvetica, Arial, sans-serif", fontFamily: "Roboto, Helvetica, Arial, sans-serif",
@ -193,10 +252,19 @@ export const RoomShow = (props: ShowProps) => {
> >
{translate("resources.rooms.helper.forward_extremities")} {translate("resources.rooms.helper.forward_extremities")}
</Box> </Box>
<ReferenceManyField reference="forward_extremities" target="room_id" label={false}> <ReferenceManyField
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="id" sortable={false} /> <TextField source="id" sortable={false} />
<DateField source="received_ts" showTime options={DATE_FORMAT} sortable={false} /> <DateField
source="received_ts"
showTime
options={date_format}
sortable={false}
/>
<NumberField source="depth" sortable={false} /> <NumberField source="depth" sortable={false} />
<TextField source="state_group" sortable={false} /> <TextField source="state_group" sortable={false} />
</Datagrid> </Datagrid>
@ -228,7 +296,7 @@ const RoomListActions = () => (
</TopToolbar> </TopToolbar>
); );
export const RoomList = (props: ListProps) => { export const RoomList = props => {
const theme = useTheme(); const theme = useTheme();
return ( return (
@ -242,7 +310,12 @@ export const RoomList = (props: ListProps) => {
<DatagridConfigurable <DatagridConfigurable
rowClick="show" rowClick="show"
bulkActionButtons={<RoomBulkActionButtons />} bulkActionButtons={<RoomBulkActionButtons />}
omit={["joined_local_members", "state_events", "version", "federatable"]} omit={[
"joined_local_members",
"state_events",
"version",
"federatable",
]}
> >
<BooleanField <BooleanField
source="is_encrypted" source="is_encrypted"
@ -255,7 +328,12 @@ export const RoomList = (props: ListProps) => {
[`& [data-testid="false"]`]: { color: theme.palette.error.main }, [`& [data-testid="false"]`]: { color: theme.palette.error.main },
}} }}
/> />
<FunctionField source="name" render={record => record["name"] || record["canonical_alias"] || record["id"]} /> <FunctionField
source="name"
render={record =>
record["name"] || record["canonical_alias"] || record["id"]
}
/>
<TextField source="joined_members" /> <TextField source="joined_members" />
<TextField source="joined_local_members" /> <TextField source="joined_local_members" />
<TextField source="state_events" /> <TextField source="state_events" />
@ -267,7 +345,7 @@ export const RoomList = (props: ListProps) => {
); );
}; };
const resource: ResourceProps = { const resource = {
name: "rooms", name: "rooms",
icon: RoomIcon, icon: RoomIcon,
list: RoomList, list: RoomList,

View File

@ -0,0 +1,79 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const { sort, resource, displayedFilters, filterValues, showFilter, total } =
useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = props => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid
rowClick={(id, resource, record) => "/users/" + id + "/media"}
bulkActionButtons={false}
>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
const resource = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
export default resource;

538
src/components/users.jsx Normal file
View File

@ -0,0 +1,538 @@
import React, { cloneElement } from "react";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import {
ArrayInput,
ArrayField,
Button,
Datagrid,
DateField,
Create,
Edit,
List,
Toolbar,
SimpleForm,
SimpleFormIterator,
TabbedForm,
FormTab,
BooleanField,
BooleanInput,
PasswordInput,
TextField,
TextInput,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
maxLength,
regex,
required,
useRecordContext,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
sanitizeListRestProps,
NumberField,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "./AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices";
import { ProtectMediaButton, QuarantineMediaButton } from "./media";
const choices_medium = [
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
];
const choices_type = [
{ id: "bot", name: "bot" },
{ id: "support", name: "support" },
];
const date_format = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const UserListActions = ({
sort,
className,
resource,
filters,
displayedFilters,
exporter, // you can hide ExportButton if exporter = (null || false)
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
selectedIds,
onUnselectItems,
showFilter,
maxResults,
total,
...rest
}) => {
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<CreateButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
{/* Add your custom actions */}
<Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
</Button>
</TopToolbar>
);
};
UserListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
};
const UserPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
<BooleanInput
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>,
];
const UserBulkActionButtons = () => (
<>
<ServerNoticeBulkButton />
<BulkDeleteButton
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</>
);
export const UserList = props => (
<List
{...props}
filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}>
<AvatarField
source="avatar_src"
sx={{ height: "40px", width: "40px" }}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<BooleanField source="deactivated" />
<DateField
source="creation_ts"
label="resources.users.fields.creation_ts_ms"
showTime
options={date_format}
/>
</Datagrid>
</List>
);
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
// maxLength = 255 - "@" - ":" - localStorage.getItem("home_server").length
// localStorage.getItem("home_server").length is not valid here
const validateUser = [
required(),
maxLength(253),
regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id"),
];
const validateAddress = [required(), maxLength(255)];
export function generateRandomUser() {
const homeserver = localStorage.getItem("home_server");
const user_id =
"@" +
Array(8)
.fill("0123456789abcdefghijklmnopqrstuvwxyz")
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] /
(0xffffffff + 1)) *
x.length
)
]
)
.join("") +
":" +
homeserver;
const password = Array(20)
.fill(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$"
)
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1)) *
x.length
)
]
)
.join("");
return {
id: user_id,
password: password,
};
}
const UserEditToolbar = props => (
<Toolbar {...props}>
<SaveButton disabled={props.pristine} />
</Toolbar>
);
const UserEditActions = ({ data }) => {
const translate = useTranslate();
var userStatus = "";
if (data) {
userStatus = data.deactivated;
}
return (
<TopToolbar>
{!userStatus && <ServerNoticeButton record={data} />}
<DeleteButton
record={data}
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
/>
</TopToolbar>
);
};
export const UserCreate = props => (
<Create {...props}>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput
source="password"
autoComplete="new-password"
validate={maxLength(512)}
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
/>
<BooleanInput source="admin" />
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput
source="medium"
choices={choices_medium}
validate={required()}
/>
<TextInput source="address" validate={validateAddress} />
</SimpleFormIterator>
</ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
const UserTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = props => {
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab
label={translate("resources.users.name", { smart_count: 1 })}
icon={<PersonPinIcon />}
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "120px", width: "120px", float: "right" }}
/>
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput
source="password"
autoComplete="new-password"
helperText="resources.users.helper.password"
/>
<SelectInput
source="user_type"
choices={choices_type}
translateChoice={false}
resettable
/>
<BooleanInput source="admin" />
<BooleanInput
source="deactivated"
helperText="resources.users.helper.deactivate"
/>
<DateField source="creation_ts_ms" showTime options={date_format} />
<TextField source="consent_version" />
</FormTab>
<FormTab
label="resources.users.threepid"
icon={<ContactMailIcon />}
path="threepid"
>
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} />
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label="synapseadmin.users.tabs.sso"
icon={<AssignmentIndIcon />}
path="sso"
>
<ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput
source="external_id"
label="resources.users.fields.id"
validate={required()}
/>
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label={translate("resources.devices.name", { smart_count: 2 })}
icon={<DevicesIcon />}
path="devices"
>
<ReferenceManyField
reference="devices"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField
source="last_seen_ts"
showTime
options={date_format}
sortable={false}
/>
<DeviceRemoveButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label="resources.connections.name"
icon={<SettingsInputComponentIcon />}
path="connections"
>
<ReferenceField
reference="connections"
source="id"
addLabel={false}
link={false}
>
<ArrayField
source="devices[].sessions[0].connections"
label="resources.connections.name"
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="ip" sortable={false} />
<DateField
source="last_seen"
showTime
options={date_format}
sortable={false}
/>
<TextField
source="user_agent"
sortable={false}
style={{ width: "100%" }}
/>
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
<FormTab
label={translate("resources.users_media.name", { smart_count: 2 })}
icon={<PermMediaIcon />}
path="media"
>
<ReferenceManyField
reference="users_media"
target="user_id"
addLabel={false}
pagination={<UserPagination />}
perPage={50}
sort={{ field: "created_ts", order: "DESC" }}
>
<Datagrid style={{ width: "100%" }}>
<DateField source="created_ts" showTime options={date_format} />
<DateField
source="last_access_ts"
showTime
options={date_format}
/>
<TextField source="media_id" />
<NumberField source="media_length" />
<TextField source="media_type" />
<TextField source="upload_name" />
<TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
<DeleteButton mutationMode="pessimistic" redirect={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<ViewListIcon />}
path="rooms"
>
<ReferenceManyField
reference="joined_rooms"
target="user_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/rooms/" + id + "/show"}
bulkActionButtons={false}
>
<TextField
source="id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.pushers.name", { smart_count: 2 })}
icon={<NotificationsIcon />}
path="pushers"
>
<ReferenceManyField
reference="pushers"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
<TextField source="data.url" sortable={false} />
<TextField source="device_display_name" sortable={false} />
<TextField source="lang" sortable={false} />
<TextField source="profile_tag" sortable={false} />
<TextField source="pushkey" sortable={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};
const resource = {
name: "users",
icon: UserIcon,
list: UserList,
edit: UserEdit,
create: UserCreate,
};
export default resource;

View File

@ -1,23 +1,12 @@
import { formalGermanMessages } from "@haleos/ra-language-german"; import germanMessages from "ra-language-german";
import { SynapseTranslationMessages } from "."; const de = {
...germanMessages,
const de: SynapseTranslationMessages = {
...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Heimserver URL", base_url: "Heimserver URL",
welcome: "Willkommen bei Synapse-admin", welcome: "Willkommen bei Synapse-admin",
server_version: "Synapse Version", server_version: "Synapse Version",
supports_specs: "unterstützt Matrix-Specs",
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'", username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
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",
@ -54,9 +43,11 @@ const de: SynapseTranslationMessages = {
cards: { cards: {
importstats: { importstats: {
header: "Benutzer importieren", header: "Benutzer importieren",
users_total: "%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei", users_total:
"%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste", guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
admin_count: "%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren", admin_count:
"%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
}, },
conflicts: { conflicts: {
header: "Konfliktstrategie", header: "Konfliktstrategie",
@ -68,7 +59,8 @@ const de: SynapseTranslationMessages = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "IDs in jedem Eintrag vorhanden", all_ids_present: "IDs in jedem Eintrag vorhanden",
count_ids_present: "%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs", count_ids_present:
"%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
mode: { mode: {
ignore: "Ignoriere IDs der CSV-Datei und erstelle neue", ignore: "Ignoriere IDs der CSV-Datei und erstelle neue",
update: "Aktualisiere existierende Benutzer", update: "Aktualisiere existierende Benutzer",
@ -77,7 +69,8 @@ const de: SynapseTranslationMessages = {
passwords: { passwords: {
header: "Passwörter", header: "Passwörter",
all_passwords_present: "Passwörter in jedem Eintrag vorhanden", all_passwords_present: "Passwörter in jedem Eintrag vorhanden",
count_passwords_present: "%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern", count_passwords_present:
"%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
use_passwords: "Verwende Passwörter aus der CSV Datei", use_passwords: "Verwende Passwörter aus der CSV Datei",
}, },
upload: { upload: {
@ -91,31 +84,17 @@ const de: SynapseTranslationMessages = {
}, },
results: { results: {
header: "Ergebnis", header: "Ergebnis",
total: "%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt", total:
"%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
successful: "%{smart_count} Einträge erfolgreich importiert", successful: "%{smart_count} Einträge erfolgreich importiert",
skipped: "%{smart_count} Einträge übersprungen", skipped: "%{smart_count} Einträge übersprungen",
download_skipped: "Übersprungene Einträge herunterladen", download_skipped: "Übersprungene Einträge herunterladen",
with_error: "%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern", with_error:
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
simulated_only: "Import-Vorgang war nur simuliert", simulated_only: "Import-Vorgang war nur simuliert",
}, },
}, },
}, },
delete_media: {
name: "Medien",
fields: {
before_ts: "Letzter Zugriff vor",
size_gt: "Größer als (in Bytes)",
keep_profiles: "Behalte Profilbilder",
},
action: {
send: "Medien löschen",
send_success: "Anfrage erfolgreich versendet.",
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
},
helper: {
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
},
},
resources: { resources: {
users: { users: {
name: "Benutzer", name: "Benutzer",
@ -128,9 +107,7 @@ const de: SynapseTranslationMessages = {
name: "Name", name: "Name",
is_guest: "Gast", is_guest: "Gast",
admin: "Server Administrator", admin: "Server Administrator",
locked: "Gesperrt",
deactivated: "Deaktiviert", deactivated: "Deaktiviert",
erased: "Gelöscht",
guests: "Zeige Gäste", guests: "Zeige Gäste",
show_deactivated: "Zeige deaktivierte Benutzer", show_deactivated: "Zeige deaktivierte Benutzer",
user_id: "Suche Benutzer", user_id: "Suche Benutzer",
@ -147,8 +124,10 @@ const de: SynapseTranslationMessages = {
user_type: "Benutzertyp", user_type: "Benutzertyp",
}, },
helper: { helper: {
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.", password:
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.", "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate:
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten", erase: "DSGVO konformes Löschen der Benutzerdaten",
}, },
action: { action: {
@ -228,10 +207,6 @@ const de: SynapseTranslationMessages = {
format: "Nachrichtenformat", format: "Nachrichtenformat",
formatted_body: "Formatierter Nachrichteninhalt", formatted_body: "Formatierter Nachrichteninhalt",
algorithm: "Verschlüsselungsalgorithmus", algorithm: "Verschlüsselungsalgorithmus",
info: {
mimetype: "Typ",
},
url: "URL",
}, },
}, },
}, },
@ -280,8 +255,21 @@ const de: SynapseTranslationMessages = {
created_ts: "Erstellt", created_ts: "Erstellt",
last_access_ts: "Letzter Zugriff", last_access_ts: "Letzter Zugriff",
}, },
},
delete_media: {
name: "Medien",
fields: {
before_ts: "Letzter Zugriff vor",
size_gt: "Größer als (in Bytes)",
keep_profiles: "Behalte Profilbilder",
},
action: { action: {
open: "Mediendatei in neuem Fenster öffnen", send: "Medien löschen",
send_success: "Anfrage erfolgreich versendet.",
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
},
helper: {
send: "Diese API löscht die lokalen Medien von der Festplatte des eigenen Servers. Dies umfasst alle lokalen Miniaturbilder und Kopien von Medien. Diese API wirkt sich nicht auf Medien aus, die sich in externen Medien-Repositories befinden.",
}, },
}, },
protect_media: { protect_media: {
@ -364,7 +352,8 @@ const de: SynapseTranslationMessages = {
guest_can_join: "Gastbenutzer dürfen beitreten", guest_can_join: "Gastbenutzer dürfen beitreten",
}, },
action: { action: {
title: "Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen", title:
"Raum aus Verzeichnis löschen |||| %{smart_count} Räume aus Verzeichnis löschen",
content: content:
"Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?", "Möchten Sie den Raum wirklich aus dem Raumverzeichnis löschen? |||| Möchten Sie die %{smart_count} Räume wirklich aus dem Raumverzeichnis löschen?",
erase: "Lösche aus Verzeichnis", erase: "Lösche aus Verzeichnis",
@ -399,5 +388,37 @@ const de: SynapseTranslationMessages = {
helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." }, helper: { length: "Länge des Tokens, wenn kein Token vorgegeben wird." },
}, },
}, },
ra: {
...germanMessages.ra,
action: {
...germanMessages.ra.action,
unselect: "Abwählen",
},
auth: {
...germanMessages.ra.auth,
auth_check_error: "Anmeldung fehlgeschlagen",
},
input: {
...germanMessages.ra.input,
password: {
...germanMessages.ra.input.password,
toggle_hidden: "Anzeigen",
toggle_visible: "Verstecken",
},
},
notification: {
...germanMessages.ra.notification,
logged_out: "Abgemeldet",
},
page: {
...germanMessages.ra.page,
empty: "Keine Einträge vorhanden",
invite: "",
},
navigation: {
...germanMessages.ra.navigation,
skip_nav: "Zum Inhalt springen",
},
},
}; };
export default de; export default de;

View File

@ -1,15 +1,12 @@
import englishMessages from "ra-language-english"; import englishMessages from "ra-language-english";
import { SynapseTranslationMessages } from "."; const en = {
const en: SynapseTranslationMessages = {
...englishMessages, ...englishMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Homeserver URL", base_url: "Homeserver URL",
welcome: "Welcome to Synapse-admin", welcome: "Welcome to Synapse-admin",
server_version: "Synapse version", server_version: "Synapse version",
supports_specs: "supports Matrix specs",
username_error: "Please enter fully qualified user ID: '@user:domain'", username_error: "Please enter fully qualified user ID: '@user:domain'",
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",
@ -20,7 +17,6 @@ const en: SynapseTranslationMessages = {
tabs: { sso: "SSO" }, tabs: { sso: "SSO" },
}, },
rooms: { rooms: {
details: "Room details",
tabs: { tabs: {
basic: "Basic", basic: "Basic",
members: "Members", members: "Members",
@ -35,8 +31,10 @@ const en: SynapseTranslationMessages = {
at_entry: "At entry %{entry}: %{message}", at_entry: "At entry %{entry}: %{message}",
error: "Error", error: "Error",
required_field: "Required field '%{field}' is not present", required_field: "Required field '%{field}' is not present",
invalid_value: "Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'", invalid_value:
unreasonably_big: "Refused to load unreasonably big file of %{size} megabytes", "Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
unreasonably_big:
"Refused to load unreasonably big file of %{size} megabytes",
already_in_progress: "An import run is already in progress", already_in_progress: "An import run is already in progress",
id_exits: "ID %{id} already present", id_exits: "ID %{id} already present",
}, },
@ -45,7 +43,8 @@ const en: SynapseTranslationMessages = {
cards: { cards: {
importstats: { importstats: {
header: "Import users", header: "Import users",
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file", users_total:
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests", guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins", admin_count: "%{smart_count} admin |||| %{smart_count} admins",
}, },
@ -59,7 +58,8 @@ const en: SynapseTranslationMessages = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "IDs present on every entry", all_ids_present: "IDs present on every entry",
count_ids_present: "%{smart_count} entry with ID |||| %{smart_count} entries with IDs", count_ids_present:
"%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
mode: { mode: {
ignore: "Ignore IDs in CSV and create new ones", ignore: "Ignore IDs in CSV and create new ones",
update: "Update existing records", update: "Update existing records",
@ -68,7 +68,8 @@ const en: SynapseTranslationMessages = {
passwords: { passwords: {
header: "Passwords", header: "Passwords",
all_passwords_present: "Passwords present on every entry", all_passwords_present: "Passwords present on every entry",
count_passwords_present: "%{smart_count} entry with password |||| %{smart_count} entries with passwords", count_passwords_present:
"%{smart_count} entry with password |||| %{smart_count} entries with passwords",
use_passwords: "Use passwords from CSV", use_passwords: "Use passwords from CSV",
}, },
upload: { upload: {
@ -82,31 +83,17 @@ const en: SynapseTranslationMessages = {
}, },
results: { results: {
header: "Import results", header: "Import results",
total: "%{smart_count} entry in total |||| %{smart_count} entries in total", total:
"%{smart_count} entry in total |||| %{smart_count} entries in total",
successful: "%{smart_count} entries successfully imported", successful: "%{smart_count} entries successfully imported",
skipped: "%{smart_count} entries skipped", skipped: "%{smart_count} entries skipped",
download_skipped: "Download skipped records", download_skipped: "Download skipped records",
with_error: "%{smart_count} entry with errors |||| %{smart_count} entries with errors", with_error:
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
simulated_only: "Run was only simulated", simulated_only: "Run was only simulated",
}, },
}, },
}, },
delete_media: {
name: "Media",
fields: {
before_ts: "last access before",
size_gt: "Larger then (in bytes)",
keep_profiles: "Keep profile images",
},
action: {
send: "Delete media",
send_success: "Request successfully sent.",
send_failure: "An error has occurred.",
},
helper: {
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
},
},
resources: { resources: {
users: { users: {
name: "User |||| Users", name: "User |||| Users",
@ -119,12 +106,9 @@ const en: SynapseTranslationMessages = {
name: "Name", name: "Name",
is_guest: "Guest", is_guest: "Guest",
admin: "Server Administrator", admin: "Server Administrator",
locked: "Locked",
deactivated: "Deactivated", deactivated: "Deactivated",
erased: "Erased",
guests: "Show guests", guests: "Show guests",
show_deactivated: "Show deactivated users", show_deactivated: "Show deactivated users",
show_locked: "Show locked users",
user_id: "Search user", user_id: "Search user",
displayname: "Displayname", displayname: "Displayname",
password: "Password", password: "Password",
@ -220,17 +204,14 @@ const en: SynapseTranslationMessages = {
format: "format", format: "format",
formatted_body: "formatted content", formatted_body: "formatted content",
algorithm: "algorithm", algorithm: "algorithm",
url: "URL",
info: {
mimetype: "Type",
},
}, },
}, },
}, },
action: { action: {
erase: { erase: {
title: "Delete reported event", title: "Delete reported event",
content: "Are you sure you want to delete the reported event? This cannot be undone.", content:
"Are you sure you want to delete the reported event? This cannot be undone.",
}, },
}, },
}, },
@ -271,8 +252,21 @@ const en: SynapseTranslationMessages = {
created_ts: "Created", created_ts: "Created",
last_access_ts: "Last access", last_access_ts: "Last access",
}, },
},
delete_media: {
name: "Media",
fields: {
before_ts: "last access before",
size_gt: "Larger then (in bytes)",
keep_profiles: "Keep profile images",
},
action: { action: {
open: "Open media file in new window", send: "Delete media",
send_success: "Request successfully sent.",
send_failure: "An error has occurred.",
},
helper: {
send: "This API deletes the local media from the disk of your own server. This includes any local thumbnails and copies of media downloaded. This API will not affect media that has been uploaded to external media repositories.",
}, },
}, },
protect_media: { protect_media: {
@ -355,7 +349,8 @@ const en: SynapseTranslationMessages = {
guest_can_join: "guest users may join", guest_can_join: "guest users may join",
}, },
action: { action: {
title: "Delete room from directory |||| Delete %{smart_count} rooms from directory", title:
"Delete room from directory |||| Delete %{smart_count} rooms from directory",
content: content:
"Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory?", "Are you sure you want to remove this room from directory? |||| Are you sure you want to remove these %{smart_count} rooms from directory?",
erase: "Delete from room directory", erase: "Delete from room directory",
@ -376,19 +371,19 @@ const en: SynapseTranslationMessages = {
}, },
action: { reconnect: "Reconnect" }, action: { reconnect: "Reconnect" },
}, },
registration_tokens: { },
name: "Registration tokens", registration_tokens: {
fields: { name: "Registration tokens",
token: "Token", fields: {
valid: "Valid token", token: "Token",
uses_allowed: "Uses allowed", valid: "Valid token",
pending: "Pending", uses_allowed: "Uses allowed",
completed: "Completed", pending: "Pending",
expiry_time: "Expiry time", completed: "Completed",
length: "Length", expiry_time: "Expiry time",
}, length: "Length",
helper: { length: "Length of the token if no token is given." },
}, },
helper: { length: "Length of the token if no token is given." },
}, },
}; };
export default en; export default en;

View File

@ -1,8 +1,6 @@
import farsiMessages from "ra-language-farsi"; import farsiMessages from "ra-language-farsi";
import { SynapseTranslationMessages } from "."; const fa = {
const fa: SynapseTranslationMessages = {
...farsiMessages, ...farsiMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
@ -33,8 +31,10 @@ const fa: SynapseTranslationMessages = {
at_entry: "در هنگام ورود %{entry}: %{message}", at_entry: "در هنگام ورود %{entry}: %{message}",
error: "Error", error: "Error",
required_field: "فیلد الزامی '%{field}' وجود ندارد", required_field: "فیلد الزامی '%{field}' وجود ندارد",
invalid_value: "خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد", invalid_value:
unreasonably_big: "از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت", "خطا در خط %{row}. '%{field}' فیلد ممکن است فقط 'درست' یا 'نادرست' باشد",
unreasonably_big:
"از بارگذاری فایل هایی با حجم غیر منطقی خودداری کنید %{size} مگابایت",
already_in_progress: "یک بارگذاری از قبل در حال انجام است", already_in_progress: "یک بارگذاری از قبل در حال انجام است",
id_exits: "شناسه %{id} موجود است", id_exits: "شناسه %{id} موجود است",
}, },
@ -43,7 +43,8 @@ const fa: SynapseTranslationMessages = {
cards: { cards: {
importstats: { importstats: {
header: "وارد کردن کاربران", header: "وارد کردن کاربران",
users_total: "%{smart_count} user in CSV file |||| %{smart_count} users in CSV file", users_total:
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests", guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins", admin_count: "%{smart_count} admin |||| %{smart_count} admins",
}, },
@ -57,7 +58,8 @@ const fa: SynapseTranslationMessages = {
ids: { ids: {
header: "شناسنامه ها", header: "شناسنامه ها",
all_ids_present: "شناسه های موجود در هر ورودی", all_ids_present: "شناسه های موجود در هر ورودی",
count_ids_present: "%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه", count_ids_present:
"%{smart_count} ورود با شناسه |||| %{smart_count} ورودی با شناسه",
mode: { mode: {
ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن", ignore: "شناسه ها را در CSV نادیده بگیر و شناسه های جدید ایجاد کن",
update: "سوابق موجود را به روز کنید", update: "سوابق موجود را به روز کنید",
@ -66,7 +68,8 @@ const fa: SynapseTranslationMessages = {
passwords: { passwords: {
header: "رمز عبور", header: "رمز عبور",
all_passwords_present: "رمزهای عبور موجود در هر ورودی", all_passwords_present: "رمزهای عبور موجود در هر ورودی",
count_passwords_present: "%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور", count_passwords_present:
"%{smart_count} ورود با رمز عبور |||| %{smart_count} ورودی با رمز عبور",
use_passwords: "از پسوردهای CSV استفاده کنید", use_passwords: "از پسوردهای CSV استفاده کنید",
}, },
upload: { upload: {
@ -84,27 +87,12 @@ const fa: SynapseTranslationMessages = {
successful: "%{smart_count} ورودی ها با موفقیت وارد شدند", successful: "%{smart_count} ورودی ها با موفقیت وارد شدند",
skipped: "%{smart_count} ورودی ها نادیده گرفته شدند", skipped: "%{smart_count} ورودی ها نادیده گرفته شدند",
download_skipped: "دانلود رکوردهای نادیده گرفته شده", download_skipped: "دانلود رکوردهای نادیده گرفته شده",
with_error: "%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا", with_error:
"%{smart_count} ورود با خطا ||| %{smart_count} ورودی های دارای خطا",
simulated_only: "اجرا فقط شبیه سازی شد", simulated_only: "اجرا فقط شبیه سازی شد",
}, },
}, },
}, },
delete_media: {
name: "رسانه ها",
fields: {
before_ts: "آخرین دسترسی قبل",
size_gt: "بزرگتر از آن (به بایت)",
keep_profiles: "تصاویر پروفایل را نگه دارید",
},
action: {
send: "حذف رسانه ها",
send_success: "درخواست با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانه‌هایی که در مخازن رسانه خارجی آپلود شده‌اند تأثیری نخواهد گذاشت.",
},
},
resources: { resources: {
users: { users: {
name: "کاربر |||| کاربران", name: "کاربر |||| کاربران",
@ -238,7 +226,8 @@ const fa: SynapseTranslationMessages = {
action: { action: {
erase: { erase: {
title: "حذف کردن %{id}", title: "حذف کردن %{id}",
content: 'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?', content:
'آیا مطمئن هستید که می خواهید دستگاه را حذف کنید؟ "%{name}"?',
success: "دستگاه با موفقیت حذف شد.", success: "دستگاه با موفقیت حذف شد.",
failure: "خطایی رخ داده است.", failure: "خطایی رخ داده است.",
}, },
@ -257,6 +246,22 @@ const fa: SynapseTranslationMessages = {
last_access_ts: "آخرین دسترسی", last_access_ts: "آخرین دسترسی",
}, },
}, },
delete_media: {
name: "رسانه ها",
fields: {
before_ts: "آخرین دسترسی قبل",
size_gt: "بزرگتر از آن (به بایت)",
keep_profiles: "تصاویر پروفایل را نگه دارید",
},
action: {
send: "حذف رسانه ها",
send_success: "درخواست با موفقیت ارسال شد.",
send_failure: "خطایی رخ داده است.",
},
helper: {
send: "این API رسانه های محلی را از دیسک سرور خود حذف می کند. این شامل هر تصویر کوچک محلی و کپی از رسانه دانلود شده است. این API بر رسانه‌هایی که در مخازن رسانه خارجی آپلود شده‌اند تأثیری نخواهد گذاشت.",
},
},
protect_media: { protect_media: {
action: { action: {
create: "محافظت نشده، حفاظت ایجاد کنید", create: "محافظت نشده، حفاظت ایجاد کنید",
@ -337,7 +342,8 @@ const fa: SynapseTranslationMessages = {
guest_can_join: "کاربران مهمان ممکن است ملحق شوند", guest_can_join: "کاربران مهمان ممکن است ملحق شوند",
}, },
action: { action: {
title: "اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری", title:
"اتاق را از فهرست حذف کنید |||| حذف کنید %{smart_count} اتاق ها از دایرکتوری",
content: content:
"آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟", "آیا مطمئنید که می خواهید این اتاق را از فهرست راهنمای حذف کنید؟ |||| آیا مطمئن هستید که می خواهید این موارد را %{smart_count} از راهنمای اتاق ها حذف کنید؟",
erase: "حذف از فهرست اتاق", erase: "حذف از فهرست اتاق",
@ -358,19 +364,19 @@ const fa: SynapseTranslationMessages = {
}, },
action: { reconnect: "دوباره وصل شوید" }, action: { reconnect: "دوباره وصل شوید" },
}, },
registration_tokens: { },
name: "توکن های ثبت نام", registration_tokens: {
fields: { name: "توکن های ثبت نام",
token: "توکن", fields: {
valid: "توکن معتبر", token: "توکن",
uses_allowed: "موارد استفاده مجاز", valid: "توکن معتبر",
pending: "انتظار", uses_allowed: "موارد استفاده مجاز",
completed: "تکمیل شد", pending: "انتظار",
expiry_time: "زمان انقضا", completed: "تکمیل شد",
length: "طول", expiry_time: "زمان انقضا",
}, length: "طول",
helper: { length: "طول توکن در صورت عدم ارائه توکن." },
}, },
helper: { length: "طول توکن در صورت عدم ارائه توکن." },
}, },
}; };
export default fa; export default fa;

View File

@ -1,21 +1,21 @@
import frenchMessages from "ra-language-french"; import frenchMessages from "ra-language-french";
import { SynapseTranslationMessages } from "."; const fr = {
const fr: SynapseTranslationMessages = {
...frenchMessages, ...frenchMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "URL du serveur daccueil", base_url: "URL du serveur daccueil",
welcome: "Bienvenue sur Synapse-admin", welcome: "Bienvenue sur Synapse-admin",
server_version: "Version du serveur Synapse", server_version: "Version du serveur Synapse",
username_error: "Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »", username_error:
"Veuillez entrer un nom d'utilisateur complet : « @utilisateur:domaine »",
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 lauthentification unique", sso_sign_in: "Se connecter avec lauthentification unique",
}, },
users: { users: {
invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.", invalid_user_id:
"Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur daccueil.",
tabs: { sso: "Authentification unique" }, tabs: { sso: "Authentification unique" },
}, },
rooms: { rooms: {
@ -35,7 +35,8 @@ const fr: SynapseTranslationMessages = {
required_field: "Le champ requis « %{field} » est manquant", required_field: "Le champ requis « %{field} » est manquant",
invalid_value: invalid_value:
"Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »", "Valeur non valide à la ligne %{row}. Le champ « %{field} » ne peut être que « true » ou « false »",
unreasonably_big: "Refus de charger un fichier trop volumineux de %{size} mégaoctets", unreasonably_big:
"Refus de charger un fichier trop volumineux de %{size} mégaoctets",
already_in_progress: "Un import est déjà en cours", already_in_progress: "Un import est déjà en cours",
id_exits: "L'identifiant %{id} déjà présent", id_exits: "L'identifiant %{id} déjà présent",
}, },
@ -47,7 +48,8 @@ const fr: SynapseTranslationMessages = {
users_total: users_total:
"%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV", "%{smart_count} utilisateur dans le fichier CSV |||| %{smart_count} utilisateurs dans le fichier CSV",
guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs", guest_count: "%{smart_count} visiteur |||| %{smart_count} visiteurs",
admin_count: "%{smart_count} administrateur |||| %{smart_count} administrateurs", admin_count:
"%{smart_count} administrateur |||| %{smart_count} administrateurs",
}, },
conflicts: { conflicts: {
header: "Stratégie de résolution des conflits", header: "Stratégie de résolution des conflits",
@ -59,9 +61,11 @@ const fr: SynapseTranslationMessages = {
ids: { ids: {
header: "Identifiants", header: "Identifiants",
all_ids_present: "Identifiants présents pour chaque entrée", all_ids_present: "Identifiants présents pour chaque entrée",
count_ids_present: "%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant", count_ids_present:
"%{smart_count} entrée avec identifiant |||| %{smart_count} entrées avec identifiant",
mode: { mode: {
ignore: "Ignorer les identifiants dans le ficher CSV et en créer de nouveaux", ignore:
"Ignorer les identifiants dans le ficher CSV et en créer de nouveaux",
update: "Mettre à jour les enregistrements existants", update: "Mettre à jour les enregistrements existants",
}, },
}, },
@ -83,31 +87,17 @@ const fr: SynapseTranslationMessages = {
}, },
results: { results: {
header: "Résultats de l'import", header: "Résultats de l'import",
total: "%{smart_count} entrée au total |||| %{smart_count} entrées au total", total:
"%{smart_count} entrée au total |||| %{smart_count} entrées au total",
successful: "%{smart_count} entrées importées avec succès", successful: "%{smart_count} entrées importées avec succès",
skipped: "%{smart_count} entrées ignorées", skipped: "%{smart_count} entrées ignorées",
download_skipped: "Télécharger les entrées ignorées", download_skipped: "Télécharger les entrées ignorées",
with_error: "%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs", with_error:
"%{smart_count} entrée avec des erreurs ||| %{smart_count} entrées avec des erreurs",
simulated_only: "L'import était simulé", simulated_only: "L'import était simulé",
}, },
}, },
}, },
delete_media: {
name: "Media",
fields: {
before_ts: "Dernier accès avant",
size_gt: "Plus grand que (en octets)",
keep_profiles: "Conserver les images de profil",
},
action: {
send: "Supprimer le média",
send_success: "Requête envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
},
},
resources: { resources: {
users: { users: {
name: "Utilisateur |||| Utilisateurs", name: "Utilisateur |||| Utilisateurs",
@ -120,7 +110,6 @@ const fr: SynapseTranslationMessages = {
name: "Nom", name: "Nom",
is_guest: "Visiteur", is_guest: "Visiteur",
admin: "Administrateur du serveur", admin: "Administrateur du serveur",
locked: "Verrouillé",
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",
@ -137,7 +126,8 @@ const fr: SynapseTranslationMessages = {
auth_provider: "Fournisseur d'identité", auth_provider: "Fournisseur d'identité",
}, },
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",
}, },
action: { action: {
@ -259,6 +249,22 @@ const fr: SynapseTranslationMessages = {
last_access_ts: "Dernier accès", last_access_ts: "Dernier accès",
}, },
}, },
delete_media: {
name: "Media",
fields: {
before_ts: "Dernier accès avant",
size_gt: "Plus grand que (en octets)",
keep_profiles: "Conserver les images de profil",
},
action: {
send: "Supprimer le média",
send_success: "Requête envoyée avec succès",
send_failure: "Une erreur s'est produite",
},
helper: {
send: "Cette API supprime les médias locaux du disque de votre propre serveur. Cela inclut toutes les vignettes locales et les copies des médias téléchargés. Cette API n'affectera pas les médias qui ont été téléversés dans des dépôts de médias externes.",
},
},
protect_media: { protect_media: {
action: { action: {
create: "Protéger", create: "Protéger",
@ -335,11 +341,13 @@ const fr: SynapseTranslationMessages = {
room_directory: { room_directory: {
name: "Répertoire des salons", name: "Répertoire des salons",
fields: { fields: {
world_readable: "Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre", world_readable:
"Tout utilisateur peut avoir un aperçu du salon, sans en devenir membre",
guest_can_join: "Les visiteurs peuvent rejoindre le salon", guest_can_join: "Les visiteurs peuvent rejoindre le salon",
}, },
action: { action: {
title: "Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire", title:
"Supprimer un salon du répertoire |||| Supprimer %{smart_count} salons du répertoire",
content: content:
"Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?", "Voulez-vous vraiment supprimer ce salon du répertoire ? |||| Voulez-vous vraiment supprimer ces %{smart_count} salons du répertoire ?",
erase: "Supprimer du répertoire des salons", erase: "Supprimer du répertoire des salons",
@ -348,20 +356,21 @@ const fr: SynapseTranslationMessages = {
send_failure: "Une erreur s'est produite", send_failure: "Une erreur s'est produite",
}, },
}, },
registration_tokens: { },
name: "Jetons d'inscription", registration_tokens: {
fields: { name: "Jetons d'inscription",
token: "Jeton", fields: {
valid: "Jeton valide", token: "Jeton",
uses_allowed: "Nombre d'inscription autorisées", valid: "Jeton valide",
pending: "Nombre d'inscription en cours", uses_allowed: "Nombre d'inscription autorisées",
completed: "Nombre d'inscription accomplie", pending: "Nombre d'inscription en cours",
expiry_time: "Date d'expiration", completed: "Nombre d'inscription accomplie",
length: "Longueur", expiry_time: "Date d'expiration",
}, length: "Longueur",
helper: { },
length: "Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié", helper: {
}, length:
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
}, },
}, },
}; };

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

@ -1,392 +0,0 @@
import { TranslationMessages } from "ra-core";
interface SynapseTranslationMessages extends TranslationMessages {
synapseadmin: {
auth: {
base_url: string;
welcome: string;
server_version: string;
supports_specs?: string; // TODO: fa, fr, it, zh
username_error: string;
protocol_error: string;
url_error: string;
sso_sign_in: string;
};
users: {
invalid_user_id: string;
tabs: { sso: string };
};
rooms: {
details?: string; // TODO: fa, fr, it, zh
tabs: {
basic: string;
members: string;
detail: string;
permission: string;
};
};
reports: { tabs: { basic: string; detail: string } };
};
import_users: {
error: {
at_entry: string;
error: string;
required_field: string;
invalid_value: string;
unreasonably_big: string;
already_in_progress: string;
id_exits: string;
};
title: string;
goToPdf: string;
cards: {
importstats: {
header: string;
users_total: string;
guest_count: string;
admin_count: string;
};
conflicts: {
header: string;
mode: {
stop: string;
skip: string;
};
};
ids: {
header: string;
all_ids_present: string;
count_ids_present: string;
mode: {
ignore: string;
update: string;
};
};
passwords: {
header: string;
all_passwords_present: string;
count_passwords_present: string;
use_passwords: string;
};
upload: {
header: string;
explanation: string;
};
startImport: {
simulate_only: string;
run_import: string;
};
results: {
header: string;
total: string;
successful: string;
skipped: string;
download_skipped: string;
with_error: string;
simulated_only: string;
};
};
};
delete_media: {
name: string;
fields: {
before_ts: string;
size_gt: string;
keep_profiles: string;
};
action: {
send: string;
send_success: string;
send_failure: string;
};
helper: {
send: string;
};
};
resources: {
users: {
name: string;
email: string;
msisdn: string;
threepid: string;
fields: {
avatar: string;
id: string;
name: string;
is_guest: string;
admin: string;
locked?: string; // TODO: fa, zh
deactivated: string;
erased?: string; // TODO: fa, fr, it, zh
guests: string;
show_deactivated: string;
show_locked?: string; // TODO: de, fa, fr, it, zh
user_id: string;
displayname: string;
password: string;
avatar_url: string;
avatar_src: string;
medium: string;
threepids: string;
address: string;
creation_ts_ms: string;
consent_version: string;
auth_provider?: string;
user_type?: string;
};
helper: {
password?: string;
deactivate: string;
erase: string;
};
action: {
erase: string;
};
};
rooms: {
name: string;
fields: {
room_id: string;
name: string;
canonical_alias: string;
joined_members: string;
joined_local_members: string;
joined_local_devices?: string;
state_events: string;
version: string;
is_encrypted: string;
encryption: string;
federatable: string;
public: string;
creator: string;
join_rules: string;
guest_access: string;
history_visibility: string;
topic?: string;
avatar?: string;
};
helper?: {
forward_extremities: string;
};
enums: {
join_rules: {
public: string;
knock: string;
invite: string;
private: string;
};
guest_access: {
can_join: string;
forbidden: string;
};
history_visibility: {
invited: string;
joined: string;
shared: string;
world_readable: string;
};
unencrypted: string;
};
action?: {
erase: {
title: string;
content: string;
};
};
};
reports: {
name: string;
fields: {
id: string;
received_ts: string;
user_id: string;
name: string;
score: string;
reason: string;
event_id: string;
event_json: {
origin: string;
origin_server_ts: string;
type: string;
content: {
msgtype: string;
body: string;
format: string;
formatted_body: string;
algorithm: string;
url?: string;
info?: {
mimetype: string;
};
};
};
};
action?: {
erase: {
title: string;
content: string;
};
};
};
connections: {
name: string;
fields: {
last_seen: string;
ip: string;
user_agent: string;
};
};
devices: {
name: string;
fields: {
device_id: string;
display_name: string;
last_seen_ts: string;
last_seen_ip: string;
};
action: {
erase: {
title: string;
content: string;
success: string;
failure: string;
};
};
};
users_media: {
name: string;
fields: {
media_id: string;
media_length: string;
media_type: string;
upload_name: string;
quarantined_by: string;
safe_from_quarantine: string;
created_ts: string;
last_access_ts: string;
};
action?: {
open: string;
};
};
protect_media?: {
action: {
create: string;
delete: string;
none: string;
send_success: string;
send_failure: string;
};
};
quarantine_media?: {
action: {
name: string;
create: string;
delete: string;
none: string;
send_success: string;
send_failure: string;
};
};
pushers: {
name: string;
fields: {
app: string;
app_display_name: string;
app_id: string;
device_display_name: string;
kind: string;
lang: string;
profile_tag: string;
pushkey: string;
data: {
url: string;
};
};
};
servernotices: {
name: string;
send: string;
fields: {
body: string;
};
action: {
send: string;
send_success: string;
send_failure: string;
};
helper: {
send: string;
};
};
user_media_statistics: {
name: string;
fields: {
media_count: string;
media_length: string;
};
};
forward_extremities?: {
name: string;
fields: {
id: string;
received_ts: string;
depth: string;
state_group: string;
};
};
room_state?: {
name: string;
fields: {
type: string;
content: string;
origin_server_ts: string;
sender: string;
};
};
room_directory?: {
name: string;
fields: {
world_readable: string;
guest_can_join: string;
};
action: {
title: string;
content: string;
erase: string;
create: string;
send_success: string;
send_failure: string;
};
};
destinations?: {
name: string;
fields: {
destination: string;
failure_ts: string;
retry_last_ts: string;
retry_interval: string;
last_successful_stream_ordering: string;
stream_ordering: string;
};
action: {
reconnect: string;
};
};
registration_tokens?: {
name: string;
fields: {
token: string;
valid: string;
uses_allowed: string;
pending: string;
completed: string;
expiry_time: string;
length: string;
};
helper: {
length: string;
};
};
};
}

View File

@ -1,15 +1,14 @@
import italianMessages from "ra-language-italian"; import italianMessages from "ra-language-italian";
import { SynapseTranslationMessages } from "."; const it = {
const it: SynapseTranslationMessages = {
...italianMessages, ...italianMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "URL dell'homeserver", base_url: "URL dell'homeserver",
welcome: "Benvenuto in Synapse-admin", welcome: "Benvenuto in Synapse-admin",
server_version: "Versione di Synapse", server_version: "Versione di Synapse",
username_error: "Per favore inserisci un ID utente completo: '@utente:dominio'", username_error:
"Per favore inserisci un ID utente completo: '@utente:dominio'",
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",
@ -33,8 +32,10 @@ const it: SynapseTranslationMessages = {
at_entry: "Alla voce %{entry}: %{message}", at_entry: "Alla voce %{entry}: %{message}",
error: "Errore", error: "Errore",
required_field: "Il campo '%{field}' non è presente", required_field: "Il campo '%{field}' non è presente",
invalid_value: "Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'", invalid_value:
unreasonably_big: "Impossibile caricare un file così grosso (%{size} megabyte)", "Valore non valido alla riga %{row}. '%{field}' Il campo può essere solo 'true' o 'false'",
unreasonably_big:
"Impossibile caricare un file così grosso (%{size} megabyte)",
already_in_progress: "Un import è attualmente già in caricamento", already_in_progress: "Un import è attualmente già in caricamento",
id_exits: "L'ID %{id} è già presente", id_exits: "L'ID %{id} è già presente",
}, },
@ -43,9 +44,11 @@ const it: SynapseTranslationMessages = {
cards: { cards: {
importstats: { importstats: {
header: "Importa utenti", header: "Importa utenti",
users_total: "%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV", users_total:
"%{smart_count} utente nel file CSV |||| %{smart_count} utenti nel file CSV",
guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti", guest_count: "%{smart_count} ospite |||| %{smart_count} ospiti",
admin_count: "%{smart_count} amministratore |||| %{smart_count} amministratori", admin_count:
"%{smart_count} amministratore |||| %{smart_count} amministratori",
}, },
conflicts: { conflicts: {
header: "Strategia di conflitto", header: "Strategia di conflitto",
@ -57,7 +60,8 @@ const it: SynapseTranslationMessages = {
ids: { ids: {
header: "ID", header: "ID",
all_ids_present: "ID presenti in ogni voce", all_ids_present: "ID presenti in ogni voce",
count_ids_present: "%{smart_count} voce con ID |||| %{smart_count} voci con ID", count_ids_present:
"%{smart_count} voce con ID |||| %{smart_count} voci con ID",
mode: { mode: {
ignore: "Ignora gli ID nel file CSV e creane di nuovi", ignore: "Ignora gli ID nel file CSV e creane di nuovi",
update: "Aggiorna le voci esistenti", update: "Aggiorna le voci esistenti",
@ -66,7 +70,8 @@ const it: SynapseTranslationMessages = {
passwords: { passwords: {
header: "Passwords", header: "Passwords",
all_passwords_present: "Password presenti in ogni voce", all_passwords_present: "Password presenti in ogni voce",
count_passwords_present: "%{smart_count} voce con password |||| %{smart_count} voci con password", count_passwords_present:
"%{smart_count} voce con password |||| %{smart_count} voci con password",
use_passwords: "Usa le password dal file CSV", use_passwords: "Usa le password dal file CSV",
}, },
upload: { upload: {
@ -80,31 +85,17 @@ const it: SynapseTranslationMessages = {
}, },
results: { results: {
header: "Importa i risultati", header: "Importa i risultati",
total: "%{smart_count} voce in totale |||| %{smart_count} voci in totale", total:
"%{smart_count} voce in totale |||| %{smart_count} voci in totale",
successful: "%{smart_count} voci importate con successo", successful: "%{smart_count} voci importate con successo",
skipped: "%{smart_count} voci ignorate", skipped: "%{smart_count} voci ignorate",
download_skipped: "Scarica le voci ignorate", download_skipped: "Scarica le voci ignorate",
with_error: "%{smart_count} voce con errori ||| %{smart_count} voci con errori", with_error:
"%{smart_count} voce con errori ||| %{smart_count} voci con errori",
simulated_only: "Il processo era stato solamente simulato", simulated_only: "Il processo era stato solamente simulato",
}, },
}, },
}, },
delete_media: {
name: "Media",
fields: {
before_ts: "ultimo accesso effettuato prima",
size_gt: "Più grande di (in byte)",
keep_profiles: "Mantieni le immagini del profilo",
},
action: {
send: "Cancella media",
send_success: "Richiesta inviata con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
},
},
resources: { resources: {
users: { users: {
name: "Utente |||| Utenti", name: "Utente |||| Utenti",
@ -117,7 +108,6 @@ const it: SynapseTranslationMessages = {
name: "Nome", name: "Nome",
is_guest: "Ospite", is_guest: "Ospite",
admin: "Amministratore", admin: "Amministratore",
locked: "Bloccato",
deactivated: "Disattivato", deactivated: "Disattivato",
guests: "Mostra gli ospiti", guests: "Mostra gli ospiti",
show_deactivated: "Mostra gli utenti disattivati", show_deactivated: "Mostra gli utenti disattivati",
@ -135,7 +125,8 @@ const it: SynapseTranslationMessages = {
user_type: "Tipo d'utente", user_type: "Tipo d'utente",
}, },
helper: { helper: {
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",
}, },
@ -258,6 +249,22 @@ const it: SynapseTranslationMessages = {
last_access_ts: "Ultimo accesso", last_access_ts: "Ultimo accesso",
}, },
}, },
delete_media: {
name: "Media",
fields: {
before_ts: "ultimo accesso effettuato prima",
size_gt: "Più grande di (in byte)",
keep_profiles: "Mantieni le immagini del profilo",
},
action: {
send: "Cancella media",
send_success: "Richiesta inviata con successo.",
send_failure: "C'è stato un errore.",
},
helper: {
send: "Questa API cancella i media locali dal disco del tuo server. Questo include anche ogni miniatura e copia del media scaricato. Questa API non inciderà sui media che sono stati caricati nei repository esterni.",
},
},
protect_media: { protect_media: {
action: { action: {
create: "Non protetto, proteggi", create: "Non protetto, proteggi",
@ -338,7 +345,8 @@ const it: SynapseTranslationMessages = {
guest_can_join: "gli utenti ospite possono entrare", guest_can_join: "gli utenti ospite possono entrare",
}, },
action: { action: {
title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco", title:
"Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco",
content: content:
"Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?", "Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?",
erase: "Rimuovi dall'elenco", erase: "Rimuovi dall'elenco",
@ -359,19 +367,19 @@ const it: SynapseTranslationMessages = {
}, },
action: { reconnect: "Riconnetti" }, action: { reconnect: "Riconnetti" },
}, },
registration_tokens: { },
name: "Token di registrazione", registration_tokens: {
fields: { name: "Token di registrazione",
token: "Token", fields: {
valid: "Token valido", token: "Token",
uses_allowed: "Usi permessi", valid: "Token valido",
pending: "In attesa", uses_allowed: "Usi permessi",
completed: "Completato", pending: "In attesa",
expiry_time: "Data della scadenza", completed: "Completato",
length: "Lunghezza", expiry_time: "Data della scadenza",
}, length: "Lunghezza",
helper: { length: "Lunghezza del token se non viene dato alcun token." },
}, },
helper: { length: "Lunghezza del token se non viene dato alcun token." },
}, },
}; };
export default it; export default it;

View File

@ -1,414 +0,0 @@
import russianMessages from "ra-language-russian";
import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = {
...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: {
auth: {
base_url: "Адрес домашнего сервера",
welcome: "Добро пожаловать в Synapse-admin",
server_version: "Версия Synapse",
supports_specs: "поддерживает спецификации Matrix",
username_error: "Пожалуйста, укажите полный ID пользователя: '@user:domain'",
protocol_error: "Адрес должен начинаться с 'http://' или 'https://'",
url_error: "Неверный адрес сервера Matrix",
sso_sign_in: "Вход через SSO",
},
users: {
invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.",
tabs: { sso: "SSO" },
},
rooms: {
details: "Данные комнаты",
tabs: {
basic: "Основные",
members: "Участники",
detail: "Подробности",
permission: "Права доступа",
},
},
reports: { tabs: { basic: "Основные", detail: "Подробности" } },
},
import_users: {
error: {
at_entry: "В записи %{entry}: %{message}",
error: "Ошибка",
required_field: "Отсутствует обязательное поле '%{field}'",
invalid_value: "Неверное значение в строке %{row}. Поле '%{field}' может быть либо 'true', либо 'false'",
unreasonably_big: "Отказано в загрузке слишком большого файла размером %{size} мегабайт",
already_in_progress: "Импорт уже в процессе",
id_exits: "ID %{id} уже существует",
},
title: "Импорт пользователей из CSV",
goToPdf: "Перейти к PDF",
cards: {
importstats: {
header: "Импорт пользователей",
users_total:
"%{smart_count} пользователь в CSV файле |||| %{smart_count} пользователя в CSV файле |||| %{smart_count} пользователей в CSV файле",
guest_count: "%{smart_count} гость |||| %{smart_count} гостя |||| %{smart_count} гостей",
admin_count:
"%{smart_count} администратор |||| %{smart_count} администратора |||| %{smart_count} администраторов",
},
conflicts: {
header: "Стратегия разрешения конфликтов",
mode: {
stop: "Остановка при конфликте",
skip: "Показать ошибку и пропустить при конфликте",
},
},
ids: {
header: "Идентификаторы",
all_ids_present: "Идентификаторы присутствуют в каждой записи",
count_ids_present:
"%{smart_count} запись с ID |||| %{smart_count} записи с ID |||| %{smart_count} записей с ID",
mode: {
ignore: "Игнорировать идентификаторы в CSV и создать новые",
update: "Обновить существующие записи",
},
},
passwords: {
header: "Пароли",
all_passwords_present: "Пароли присутствуют в каждой записи",
count_passwords_present:
"%{smart_count} запись с паролем |||| %{smart_count} записи с паролями |||| %{smart_count} записей с паролями",
use_passwords: "Использовать пароли из CSV",
},
upload: {
header: "Загрузить CSV файл",
explanation:
"Здесь вы можете загрузить файл со значениями, разделёнными запятыми, которые будут использованы для создания или обновления данных пользователей. \
В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ",
},
startImport: {
simulate_only: "Только симулировать",
run_import: "Импорт",
},
results: {
header: "Результаты импорта",
total: "%{smart_count} запись всего |||| %{smart_count} записи всего |||| %{smart_count} записей всего",
successful:
"%{smart_count} запись успешно импортирована |||| %{smart_count} записи успешно импортированы |||| %{smart_count} записей успешно импортированы",
skipped:
"%{smart_count} запись пропущена |||| %{smart_count} записи пропущены |||| %{smart_count} записей пропущено",
download_skipped: "Скачать пропущенные записи",
with_error:
"%{smart_count} запись с ошибкой |||| %{smart_count} записи с ошибками |||| %{smart_count} записей с ошибками",
simulated_only: "Импорт был симулирован",
},
},
},
delete_media: {
name: "Файлы",
fields: {
before_ts: "Последнее обращение до",
size_gt: "Более чем (в байтах)",
keep_profiles: "Сохранить аватары",
},
action: {
send: "Удалить файлы",
send_success: "Запрос успешно отправлен.",
send_failure: "Произошла ошибка.",
},
helper: {
send: "Это API удаляет локальные файлы с вашего собственного сервера, включая локальные миниатюры и копии скачанных файлов. \
Данный API не затрагивает файлы, загруженные во внешние хранилища.",
},
},
resources: {
users: {
name: "Пользователь |||| Пользователи",
email: "Почта",
msisdn: "Телефон",
threepid: "Почта / Телефон",
fields: {
avatar: "Аватар",
id: "ID пользователя",
name: "Имя",
is_guest: "Гость",
admin: "Администратор сервера",
locked: "Заблокирован",
deactivated: "Деактивирован",
erased: "Удалён",
guests: "Показывать гостей",
show_deactivated: "Показывать деактивированных",
user_id: "Поиск пользователя",
displayname: "Отображаемое имя",
password: "Пароль",
avatar_url: "Адрес аватары",
avatar_src: "Аватар",
medium: "Тип",
threepids: "3PID'ы",
address: "Адрес",
creation_ts_ms: "Дата создания",
consent_version: "Версия соглашения",
auth_provider: "Провайдер",
user_type: "Тип пользователя",
},
helper: {
password: "Смена пароля завершит все сессии пользователя.",
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
},
action: {
erase: "Удалить данные пользователя",
},
},
rooms: {
name: "Комната |||| Комнаты",
fields: {
room_id: "ID комнаты",
name: "Название",
canonical_alias: "Псевдоним",
joined_members: "Участники",
joined_local_members: "Локальные участники",
joined_local_devices: "Локальные устройства",
state_events: "События состояния / Сложность",
version: "Версия",
is_encrypted: "Зашифровано",
encryption: "Шифрование",
federatable: "Федерация",
public: "Отображается в каталоге комнат",
creator: "Создатель",
join_rules: "Правила входа",
guest_access: "Гостевой доступ",
history_visibility: "Видимость истории",
topic: "Тема",
avatar: "Аватар",
},
helper: {
forward_extremities:
"Оконечности это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \
Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \
Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \
Если в комнате >10 оконечностей, стоит найти комнату-виновника и попробовать удалить их с помощью SQL-запросов из #1760.",
},
enums: {
join_rules: {
public: "Для всех",
knock: "Надо постучать",
invite: "По приглашению",
private: "Приватная",
},
guest_access: {
can_join: "Гости могут войти",
forbidden: "Гости не могут войти",
},
history_visibility: {
invited: "С момента приглашения",
joined: "С момента входа",
shared: "С момента открытия доступа",
world_readable: "Для всех",
},
unencrypted: "Без шифрования",
},
action: {
erase: {
title: "Удалить комнату",
content:
"Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!",
},
},
},
reports: {
name: "Жалоба |||| Жалобы",
fields: {
id: "ID",
received_ts: "Дата и время жалобы",
user_id: "Автор жалобы",
name: "Название комнаты",
score: "Баллы",
reason: "Причина",
event_id: "ID события",
event_json: {
origin: "Исходнный сервер",
origin_server_ts: "Дата и время отправки",
type: "Тип события",
content: {
msgtype: "Тип содержимого",
body: "Содержимое",
format: "Формат",
formatted_body: "Форматированное содержимое",
algorithm: "Алгоритм",
url: "Ссылка",
info: {
mimetype: "Тип",
},
},
},
},
action: {
erase: {
title: "Удалить жалобу",
content: "Действительно удалить жалобу? Это действие будет невозможно отменить.",
},
},
},
connections: {
name: "Подключения",
fields: {
last_seen: "Дата",
ip: "IP адрес",
user_agent: "Юзер-агент",
},
},
devices: {
name: "Устройство |||| Устройства",
fields: {
device_id: "ID устройства",
display_name: "Название",
last_seen_ts: "Дата и время",
last_seen_ip: "IP адрес",
},
action: {
erase: {
title: "Удаление %{id}",
content: 'Действительно удалить устройство "%{name}"?',
success: "Устройство успешно удалено.",
failure: "Произошла ошибка.",
},
},
},
users_media: {
name: "Файлы",
fields: {
media_id: "ID файла",
media_length: "Размер файла (в байтах)",
media_type: "Тип",
upload_name: "Имя файла",
quarantined_by: "На карантине",
safe_from_quarantine: "Защитить от карантина",
created_ts: "Создано",
last_access_ts: "Последний доступ",
},
action: {
open: "Открыть файл в новом окне",
},
},
protect_media: {
action: {
create: "Не защищён, установить защиту",
delete: "Защищён, снять защиту",
none: "На карантине",
send_success: "Статус защиты успешно изменён.",
send_failure: "Произошла ошибка.",
},
},
quarantine_media: {
action: {
name: "Карантин",
create: "Поместить на карантин",
delete: "На карантине, снять карантин",
none: "Защищено от карантина",
send_success: "Статус карантина успешно изменён.",
send_failure: "Произошла ошибка.",
},
},
pushers: {
name: "Пушер |||| Пушеры",
fields: {
app: "Приложение",
app_display_name: "Название приложения",
app_id: "ID приложения",
device_display_name: "Название устройства",
kind: "Вид",
lang: "Язык",
profile_tag: "Тег профиля",
pushkey: "Ключ",
data: { url: "URL" },
},
},
servernotices: {
name: "Серверные уведомления",
send: "Отправить серверные уведомления",
fields: {
body: "Сообщение",
},
action: {
send: "Отправить",
send_success: "Серверное уведомление успешно отправлено.",
send_failure: "Произошла ошибка.",
},
helper: {
send: 'Отправить серверное уведомление выбранным пользователям. На сервере должна быть активна функция "Server Notices".',
},
},
user_media_statistics: {
name: "Файлы пользователей",
fields: {
media_count: "Количество файлов",
media_length: "Размер файлов",
},
},
forward_extremities: {
name: "Оконечности",
fields: {
id: "ID события",
received_ts: "Дата и время",
depth: "Глубина",
state_group: "Группа состояния",
},
},
room_state: {
name: "События состояния",
fields: {
type: "Тип",
content: "Содержимое",
origin_server_ts: "Дата отправки",
sender: "Отправитель",
},
},
room_directory: {
name: "Каталог комнат",
fields: {
world_readable: "Гости могут просматривать без входа",
guest_can_join: "Гости могут войти",
},
action: {
title:
"Удалить комнату из каталога |||| Удалить %{smart_count} комнаты из каталога |||| Удалить %{smart_count} комнат из каталога",
content:
"Действительно удалить комнату из каталога? |||| Действительно удалить %{smart_count} комнаты из каталога? |||| Действительно удалить %{smart_count} комнат из каталога?",
erase: "Удалить из каталога комнат",
create: "Опубликовать в каталоге комнат",
send_success: "Комната успешно опубликована.",
send_failure: "Произошла ошибка.",
},
},
destinations: {
name: "Федерация",
fields: {
destination: "Назначение",
failure_ts: "Дата и время ошибки",
retry_last_ts: "Дата и время последней попытки",
retry_interval: "Интервал между попытками",
last_successful_stream_ordering: "Последний успешный поток",
stream_ordering: "Поток",
},
action: { reconnect: "Переподключиться" },
},
registration_tokens: {
name: "Токены регистрации",
fields: {
token: "Токен",
valid: "Рабочий токен",
uses_allowed: "Количество использований",
pending: "Ожидает",
completed: "Завершено",
expiry_time: "Дата окончания",
length: "Длина",
},
helper: { length: "Длина токена, если токен не задан." },
},
},
};
export default ru;

View File

@ -1,17 +1,7 @@
import chineseMessages from "@haxqer/ra-language-chinese"; import chineseMessages from "ra-language-chinese";
import { SynapseTranslationMessages } from "."; const zh = {
const zh: SynapseTranslationMessages = {
...chineseMessages, ...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "服务器 URL", base_url: "服务器 URL",
@ -23,7 +13,8 @@ const zh: SynapseTranslationMessages = {
sso_sign_in: "使用 SSO 登录", sso_sign_in: "使用 SSO 登录",
}, },
users: { users: {
invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver", invalid_user_id:
"必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver",
tabs: { sso: "SSO" }, tabs: { sso: "SSO" },
}, },
rooms: { rooms: {
@ -33,6 +24,11 @@ const zh: SynapseTranslationMessages = {
detail: "细节", detail: "细节",
permission: "权限", permission: "权限",
}, },
delete: {
title: "删除房间",
message:
"您确定要删除这个房间吗?该操作无法被撤销。这个房间里所有的消息和分享的媒体都将被从服务器上删除!",
},
}, },
reports: { tabs: { basic: "基本", detail: "细节" } }, reports: { tabs: { basic: "基本", detail: "细节" } },
}, },
@ -41,7 +37,8 @@ const zh: SynapseTranslationMessages = {
at_entry: "在条目 %{entry}: %{message}", at_entry: "在条目 %{entry}: %{message}",
error: "错误", error: "错误",
required_field: "需要的值 '%{field}' 未被设置。", required_field: "需要的值 '%{field}' 未被设置。",
invalid_value: "第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。", invalid_value:
"第 %{row} 行出现无效值。 '%{field}' 只可以是 'true' 或 'false'。",
unreasonably_big: "拒绝加载过大的文件: %{size} MB", unreasonably_big: "拒绝加载过大的文件: %{size} MB",
already_in_progress: "一个导入进程已经在运行中", already_in_progress: "一个导入进程已经在运行中",
id_exits: "ID %{id} 已经存在", id_exits: "ID %{id} 已经存在",
@ -51,7 +48,8 @@ const zh: SynapseTranslationMessages = {
cards: { cards: {
importstats: { importstats: {
header: "导入用户", header: "导入用户",
users_total: "%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中", users_total:
"%{smart_count} 用户在 CSV 文件中 |||| %{smart_count} 用户在 CSV 文件中",
guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客", guest_count: "%{smart_count} 访客 |||| %{smart_count} 访客",
admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员", admin_count: "%{smart_count} 管理员 |||| %{smart_count} 管理员",
}, },
@ -65,7 +63,8 @@ const zh: SynapseTranslationMessages = {
ids: { ids: {
header: "IDs", header: "IDs",
all_ids_present: "每条记录的 ID", all_ids_present: "每条记录的 ID",
count_ids_present: "%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录", count_ids_present:
"%{smart_count} 个含 ID 的记录 |||| %{smart_count} 个含 ID 的记录",
mode: { mode: {
ignore: "忽略 CSV 中的 ID 并创建新的", ignore: "忽略 CSV 中的 ID 并创建新的",
update: "更新已经存在的记录", update: "更新已经存在的记录",
@ -74,7 +73,8 @@ const zh: SynapseTranslationMessages = {
passwords: { passwords: {
header: "密码", header: "密码",
all_passwords_present: "每条记录的密码", all_passwords_present: "每条记录的密码",
count_passwords_present: "%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录", count_passwords_present:
"%{smart_count} 个含密码的记录 |||| %{smart_count} 个含密码的记录",
use_passwords: "使用 CSV 中标记的密码", use_passwords: "使用 CSV 中标记的密码",
}, },
upload: { upload: {
@ -92,27 +92,12 @@ const zh: SynapseTranslationMessages = {
successful: "%{smart_count} 条记录导入成功", successful: "%{smart_count} 条记录导入成功",
skipped: "跳过 %{smart_count} 条记录", skipped: "跳过 %{smart_count} 条记录",
download_skipped: "下载跳过的记录", download_skipped: "下载跳过的记录",
with_error: "%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误", with_error:
"%{smart_count} 条记录出现错误 ||| %{smart_count} 条记录出现错误",
simulated_only: "只是一次模拟运行", simulated_only: "只是一次模拟运行",
}, },
}, },
}, },
delete_media: {
name: "媒体文件",
fields: {
before_ts: "最后访问时间",
size_gt: "大于 (字节)",
keep_profiles: "保留头像",
},
action: {
send: "删除媒体",
send_success: "请求发送成功。",
send_failure: "出现了一个错误。",
},
helper: {
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
},
},
resources: { resources: {
users: { users: {
name: "用户", name: "用户",
@ -248,6 +233,22 @@ const zh: SynapseTranslationMessages = {
last_access_ts: "上一次访问", last_access_ts: "上一次访问",
}, },
}, },
delete_media: {
name: "媒体文件",
fields: {
before_ts: "最后访问时间",
size_gt: "大于 (字节)",
keep_profiles: "保留头像",
},
action: {
send: "删除媒体",
send_success: "请求发送成功。",
send_failure: "出现了一个错误。",
},
helper: {
send: "这个API会删除您硬盘上的本地媒体。包含了任何的本地缓存和下载的媒体备份。这个API不会影响上传到外部媒体存储库上的媒体文件。",
},
},
pushers: { pushers: {
name: "发布者", name: "发布者",
fields: { fields: {

9
src/index.jsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -1,24 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { AppContext } from "./AppContext";
const baseUrl = import.meta.env.BASE_URL;
const configJSON = "config.json";
// if import.meta.env.BASE_URL have a trailing slash, remove it
// load config.json from relative path if import.meta.env.BASE_URL is None or empty
const configJSONUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/${configJSON}` : configJSON;
fetch(configJSONUrl)
.then(res => res.json())
.then(props =>
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AppContext.Provider value={props}>
<App />
</AppContext.Provider>
</React.StrictMode>
)
);

View File

@ -1 +0,0 @@
import "@testing-library/jest-dom";

View File

@ -1,73 +0,0 @@
import polyglotI18nProvider from "ra-i18n-polyglot";
import { render, screen } from "@testing-library/react";
import { AdminContext } from "react-admin";
import LoginPage from "./LoginPage";
import { AppContext } from "../AppContext";
import englishMessages from "../i18n/en";
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
const baseUrlInput = screen.getByRole("textbox", {
name: englishMessages.synapseadmin.auth.base_url,
});
expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
it("renders with single restricted homeserver", () => {
render(
<AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com" }}>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
const baseUrlInput = screen.getByRole("textbox", {
name: englishMessages.synapseadmin.auth.base_url,
});
expect(baseUrlInput.className.split(" ")).toContain("Mui-readOnly");
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
it("renders with multiple restricted homeservers", async () => {
render(
<AppContext.Provider
value={{
restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"],
}}
>
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: englishMessages.ra.auth.username });
screen.getByText(englishMessages.ra.auth.password);
screen.getByRole("combobox", {
name: englishMessages.synapseadmin.auth.base_url,
});
screen.getByRole("button", { name: englishMessages.ra.auth.sign_in });
});
});

View File

@ -1,55 +0,0 @@
import EqualizerIcon from "@mui/icons-material/Equalizer";
import {
Datagrid,
ExportButton,
List,
ListProps,
NumberField,
Pagination,
ResourceProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import { DeleteMediaButton } from "../components/media";
const ListActions = () => {
const { isLoading, total } = useListContext();
return (
<TopToolbar>
<DeleteMediaButton />
<ExportButton disabled={isLoading || total === 0} />
</TopToolbar>
);
};
const UserMediaStatsPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = (props: ListProps) => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid rowClick={id => "/users/" + id + "/media"} bulkActionButtons={false}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField source="displayname" label="resources.users.fields.displayname" />
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
const resource: ResourceProps = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
export default resource;

View File

@ -1,334 +0,0 @@
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
import GetAppIcon from "@mui/icons-material/GetApp";
import UserIcon from "@mui/icons-material/Group";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import ViewListIcon from "@mui/icons-material/ViewList";
import {
ArrayInput,
ArrayField,
Button,
Datagrid,
DateField,
Create,
CreateProps,
Edit,
EditProps,
List,
ListProps,
SimpleForm,
SimpleFormIterator,
TabbedForm,
FormTab,
BooleanField,
BooleanInput,
PasswordInput,
TextField,
TextInput,
ReferenceField,
ReferenceManyField,
ResourceProps,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
maxLength,
regex,
required,
useRecordContext,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
NumberField,
useListContext,
Identifier,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices";
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
const choices_medium = [
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
];
const choices_type = [
{ id: "bot", name: "bot" },
{ id: "support", name: "support" },
];
const UserListActions = () => {
const { isLoading, total } = useListContext();
return (
<TopToolbar>
<CreateButton />
<ExportButton disabled={isLoading || total === 0} maxResults={10000} />
<Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
</Button>
</TopToolbar>
);
};
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
<BooleanInput label="resources.users.fields.show_deactivated" source="deactivated" alwaysOn />,
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
];
const UserBulkActionButtons = () => (
<>
<ServerNoticeBulkButton />
<BulkDeleteButton
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</>
);
export const UserList = (props: ListProps) => (
<List
{...props}
filters={userFilters}
filterDefaultValues={{ guests: true, deactivated: false, locked: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions />}
pagination={<UserPagination />}
>
<Datagrid
rowClick={(id: Identifier, resource: string) => `/${resource}/${id}`}
bulkActionButtons={<UserBulkActionButtons />}
>
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" />
<TextField source="id" sortBy="name" />
<TextField source="displayname" />
<BooleanField source="is_guest" />
<BooleanField source="admin" />
<BooleanField source="deactivated" />
<BooleanField source="locked" />
<BooleanField source="erased" sortable={false} />
<DateField source="creation_ts" label="resources.users.fields.creation_ts_ms" showTime options={DATE_FORMAT} />
</Datagrid>
</List>
);
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
// maxLength = 255 - "@" - ":" - storage.getItem("home_server").length
// storage.getItem("home_server").length is not valid here
const validateUser = [required(), maxLength(253), regex(/^[a-z0-9._=\-/]+$/, "synapseadmin.users.invalid_user_id")];
const validateAddress = [required(), maxLength(255)];
const UserEditActions = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<TopToolbar>
{!record?.deactivated && <ServerNoticeButton />}
<DeleteButton
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
smart_count: 1,
})}
mutationMode="pessimistic"
/>
</TopToolbar>
);
};
export const UserCreate = (props: CreateProps) => (
<Create
{...props}
redirect={(resource: string | undefined, id: Identifier | undefined) => {
return `${resource}/${id}`;
}}
>
<SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} />
<PasswordInput source="password" autoComplete="new-password" validate={maxLength(512)} />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} validate={required()} />
<TextInput source="address" validate={validateAddress} />
</SimpleFormIterator>
</ArrayInput>
<ArrayInput source="external_ids" label="synapseadmin.users.tabs.sso">
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Create>
);
const UserTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = (props: EditProps) => {
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<TabbedForm>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} />
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<BooleanInput source="locked" />
<BooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
<BooleanInput source="erased" disabled />
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
<TextField source="consent_version" />
</FormTab>
<FormTab label="resources.users.threepid" icon={<ContactMailIcon />} path="threepid">
<ArrayInput source="threepids">
<SimpleFormIterator disableReordering>
<SelectInput source="medium" choices={choices_medium} />
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab label="synapseadmin.users.tabs.sso" icon={<AssignmentIndIcon />} path="sso">
<ArrayInput source="external_ids" label={false}>
<SimpleFormIterator disableReordering>
<TextInput source="auth_provider" validate={required()} />
<TextInput source="external_id" label="resources.users.fields.id" validate={required()} />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
<ReferenceManyField reference="devices" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField source="last_seen_ts" showTime options={DATE_FORMAT} sortable={false} />
<DeviceRemoveButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab label="resources.connections.name" icon={<SettingsInputComponentIcon />} path="connections">
<ReferenceField reference="connections" source="id" label={false} link={false}>
<ArrayField source="devices[].sessions[0].connections" label="resources.connections.name">
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="ip" sortable={false} />
<DateField source="last_seen" showTime options={DATE_FORMAT} sortable={false} />
<TextField source="user_agent" sortable={false} style={{ width: "100%" }} />
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
<FormTab
label={translate("resources.users_media.name", { smart_count: 2 })}
icon={<PermMediaIcon />}
path="media"
>
<ReferenceManyField
reference="users_media"
target="user_id"
label={false}
pagination={<UserPagination />}
perPage={50}
sort={{ field: "created_ts", order: "DESC" }}
>
<Datagrid style={{ width: "100%" }}>
<MediaIDField source="media_id" />
<DateField source="created_ts" showTime options={DATE_FORMAT} />
<DateField source="last_access_ts" showTime options={DATE_FORMAT} />
<NumberField source="media_length" />
<TextField source="media_type" />
<TextField source="upload_name" />
<TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />
<DeleteButton mutationMode="pessimistic" redirect={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab label={translate("resources.rooms.name", { smart_count: 2 })} icon={<ViewListIcon />} path="rooms">
<ReferenceManyField reference="joined_rooms" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }} rowClick={id => "/rooms/" + id + "/show"} bulkActionButtons={false}>
<TextField source="id" sortable={false} label="resources.rooms.fields.room_id" />
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.pushers.name", { smart_count: 2 })}
icon={<NotificationsIcon />}
path="pushers"
>
<ReferenceManyField reference="pushers" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
<TextField source="data.url" sortable={false} />
<TextField source="device_display_name" sortable={false} />
<TextField source="lang" sortable={false} />
<TextField source="profile_tag" sortable={false} />
<TextField source="pushkey" sortable={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);
};
const resource: ResourceProps = {
name: "users",
icon: UserIcon,
list: UserList,
edit: UserEdit,
create: UserCreate,
};
export default resource;

3
src/setupTests.js Normal file
View File

@ -0,0 +1,3 @@
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();

View File

@ -1,3 +0,0 @@
const storage = localStorage;
export default storage;

View File

@ -1,27 +1,18 @@
import { AuthProvider, Options, fetchUtils } from "react-admin"; import { fetchUtils } from "react-admin";
import storage from "../storage"; const authProvider = {
const authProvider: AuthProvider = {
// called when the user attempts to log in // called when the user attempts to log in
login: async ({ login: ({ base_url, username, password, loginToken }) => {
base_url, // force homeserver for protection in case the form is manipulated
username, base_url = process.env.REACT_APP_SERVER || base_url;
password,
loginToken,
}: {
base_url: string;
username: string;
password: string;
loginToken: string;
}) => {
console.log("login "); console.log("login ");
const options: Options = { const options = {
method: "POST", method: "POST",
body: JSON.stringify( body: JSON.stringify(
Object.assign( Object.assign(
{ {
device_id: storage.getItem("device_id"), device_id: localStorage.getItem("device_id"),
initial_device_display_name: "Synapse Admin", initial_device_display_name: "Synapse Admin",
}, },
loginToken loginToken
@ -33,10 +24,6 @@ const authProvider: AuthProvider = {
type: "m.login.password", type: "m.login.password",
user: username, user: username,
password: password, password: password,
identifier: {
type: "m.id.user",
user: username,
},
} }
) )
), ),
@ -46,25 +33,27 @@ const authProvider: AuthProvider = {
// 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
base_url = base_url.replace(/\/+$/g, ""); base_url = base_url.replace(/\/+$/g, "");
storage.setItem("base_url", base_url); localStorage.setItem("base_url", base_url);
const decoded_base_url = window.decodeURIComponent(base_url); const decoded_base_url = window.decodeURIComponent(base_url);
const login_api_url = decoded_base_url + "/_matrix/client/r0/login"; const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
const { json } = await fetchUtils.fetchJson(login_api_url, options); return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => {
storage.setItem("home_server", json.home_server); localStorage.setItem("home_server", json.home_server);
storage.setItem("user_id", json.user_id); localStorage.setItem("user_id", json.user_id);
storage.setItem("access_token", json.access_token); localStorage.setItem("access_token", json.access_token);
storage.setItem("device_id", json.device_id); localStorage.setItem("device_id", json.device_id);
});
}, },
// called when the user clicks on the logout button // called when the user clicks on the logout button
logout: async () => { logout: () => {
console.log("logout"); console.log("logout");
const logout_api_url = storage.getItem("base_url") + "/_matrix/client/r0/logout"; const logout_api_url =
const access_token = storage.getItem("access_token"); localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
const access_token = localStorage.getItem("access_token");
const options: Options = { const options = {
method: "POST", method: "POST",
user: { user: {
authenticated: true, authenticated: true,
@ -73,12 +62,14 @@ const authProvider: AuthProvider = {
}; };
if (typeof access_token === "string") { if (typeof access_token === "string") {
await fetchUtils.fetchJson(logout_api_url, options); fetchUtils.fetchJson(logout_api_url, options).then(({ json }) => {
storage.removeItem("access_token"); localStorage.removeItem("access_token");
});
} }
return Promise.resolve();
}, },
// called when the API returns an error // called when the API returns an error
checkError: ({ status }: { status: number }) => { checkError: ({ status }) => {
console.log("checkError " + status); console.log("checkError " + status);
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
return Promise.reject(); return Promise.reject();
@ -87,9 +78,11 @@ const authProvider: AuthProvider = {
}, },
// called when the user navigates to a new location, to check for authentication // called when the user navigates to a new location, to check for authentication
checkAuth: () => { checkAuth: () => {
const access_token = storage.getItem("access_token"); const access_token = localStorage.getItem("access_token");
console.log("checkAuth " + access_token); console.log("checkAuth " + access_token);
return typeof access_token === "string" ? Promise.resolve() : Promise.reject(); return typeof access_token === "string"
? Promise.resolve()
: Promise.reject();
}, },
// called when the user navigates to a new location, to check for permissions / roles // called when the user navigates to a new location, to check for permissions / roles
getPermissions: () => Promise.resolve(), getPermissions: () => Promise.resolve(),

View File

@ -1,138 +0,0 @@
import fetchMock from "jest-fetch-mock";
import authProvider from "./authProvider";
import storage from "../storage";
fetchMock.enableMocks();
describe("authProvider", () => {
beforeEach(() => {
fetchMock.resetMocks();
storage.clear();
});
describe("login", () => {
it("should successfully login with username and password", async () => {
fetchMock.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret: undefined = await authProvider.login({
base_url: "http://example.com",
username: "@user:example.com",
password: "secret",
});
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", {
body: JSON.stringify({
device_id: null,
initial_device_display_name: "Synapse Admin",
type: "m.login.password",
user: "@user:example.com",
password: "secret",
identifier: {
type: "m.id.user",
user: "@user:example.com",
}
}),
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
method: "POST",
});
expect(storage.getItem("base_url")).toEqual("http://example.com");
expect(storage.getItem("user_id")).toEqual("@user:example.com");
expect(storage.getItem("access_token")).toEqual("foobar");
expect(storage.getItem("device_id")).toEqual("some_device");
});
});
it("should successfully login with token", async () => {
fetchMock.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret: undefined = await authProvider.login({
base_url: "https://example.com/",
loginToken: "login_token",
});
expect(ret).toBe(undefined);
expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", {
body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}',
headers: new Headers({
Accept: "application/json",
"Content-Type": "application/json",
}),
method: "POST",
});
expect(storage.getItem("base_url")).toEqual("https://example.com");
expect(storage.getItem("user_id")).toEqual("@user:example.com");
expect(storage.getItem("access_token")).toEqual("foobar");
expect(storage.getItem("device_id")).toEqual("some_device");
});
describe("logout", () => {
it("should remove the access_token from storage", async () => {
storage.setItem("base_url", "example.com");
storage.setItem("access_token", "foo");
fetchMock.mockResponse(JSON.stringify({}));
await authProvider.logout(null);
expect(fetch).toBeCalledWith("example.com/_matrix/client/r0/logout", {
headers: new Headers({
Accept: "application/json",
Authorization: "Bearer foo",
}),
method: "POST",
user: { authenticated: true, token: "Bearer foo" },
});
expect(storage.getItem("access_token")).toBeNull();
});
});
describe("checkError", () => {
it("should resolve if error.status is not 401 or 403", async () => {
await expect(authProvider.checkError({ status: 200 })).resolves.toBeUndefined();
});
it("should reject if error.status is 401", async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
});
it("should reject if error.status is 403", async () => {
await expect(authProvider.checkError({ status: 403 })).rejects.toBeUndefined();
});
});
describe("checkAuth", () => {
it("should reject when not logged in", async () => {
await expect(authProvider.checkAuth({})).rejects.toBeUndefined();
});
it("should resolve when logged in", async () => {
storage.setItem("access_token", "foobar");
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
});
});
describe("getPermissions", () => {
it("should do nothing", async () => {
await expect(authProvider.getPermissions(null)).resolves.toBeUndefined();
});
});
});

592
src/synapse/dataProvider.js Normal file
View File

@ -0,0 +1,592 @@
import { fetchUtils } from "react-admin";
import { stringify } from "query-string";
// Adds the access token to all requests
const jsonClient = (url, options = {}) => {
const token = localStorage.getItem("access_token");
console.log("httpClient " + url);
if (token != null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
};
}
return fetchUtils.fetchJson(url, options);
};
const mxcUrlToHttp = mxcUrl => {
const homeserver = localStorage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
var 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`;
};
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
map: u => ({
...u,
id: u.name,
avatar_src: mxcUrlToHttp(u.avatar_url),
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
create: data => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(
data.id
)}:${localStorage.getItem("home_server")}`,
body: data,
method: "PUT",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(
params.id
)}`,
body: { erase: true },
method: "POST",
}),
},
rooms: {
path: "/_synapse/admin/v1/rooms",
map: r => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}),
data: "rooms",
total: json => {
return json.total_rooms;
},
delete: params => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
body: { block: false },
}),
},
reports: {
path: "/_synapse/admin/v1/event_reports",
map: er => ({
...er,
id: er.id,
}),
data: "event_reports",
total: json => json.total,
},
devices: {
map: d => ({
...d,
id: d.device_id,
}),
data: "devices",
total: json => {
return json.total;
},
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
}),
delete: params => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
params.previousData.user_id
)}/devices/${params.id}`,
}),
},
connections: {
path: "/_synapse/admin/v1/whois",
map: c => ({
...c,
id: c.user_id,
}),
data: "connections",
},
room_members: {
map: m => ({
id: m,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
total: json => {
return json.total;
},
},
room_state: {
map: rs => ({
...rs,
id: rs.event_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
}),
data: "state",
total: json => {
return json.state.length;
},
},
pushers: {
map: p => ({
...p,
id: p.pushkey,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
}),
data: "pushers",
total: json => {
return json.total;
},
},
joined_rooms: {
map: jr => ({
id: jr,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(
id
)}/joined_rooms`,
}),
data: "joined_rooms",
total: json => {
return json.total;
},
},
users_media: {
map: um => ({
...um,
id: um.media_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
}),
data: "media",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/${params.id}`,
}),
},
delete_media: {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/delete?before_ts=${params.meta.before_ts}&size_gt=${
params.meta.size_gt
}&keep_profiles=${params.meta.keep_profiles}`,
method: "POST",
}),
},
protect_media: {
map: pm => ({ id: pm.media_id }),
create: params => ({
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
method: "POST",
}),
},
quarantine_media: {
map: qm => ({ id: qm.media_id }),
create: params => ({
endpoint: `/_synapse/admin/v1/media/quarantine/${localStorage.getItem(
"home_server"
)}/${params.media_id}`,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem(
"home_server"
)}/${params.id}`,
method: "POST",
}),
},
servernotices: {
map: n => ({ id: n.event_id }),
create: data => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
content: {
msgtype: "m.text",
body: data.body,
},
},
method: "POST",
}),
},
user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media",
map: usms => ({
...usms,
id: usms.user_id,
}),
data: "users",
total: json => {
return json.total;
},
},
forward_extremities: {
map: fe => ({
...fe,
id: fe.event_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
}),
data: "results",
total: json => {
return json.count;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
}),
},
room_directory: {
path: "/_matrix/client/r0/publicRooms",
map: rd => ({
...rd,
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
avatar_src: mxcUrlToHttp(rd.avatar_url),
}),
data: "chunk",
total: json => {
return json.total_room_count_estimate;
},
create: params => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "public" },
method: "PUT",
}),
delete: params => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "private" },
method: "PUT",
}),
},
destinations: {
path: "/_synapse/admin/v1/federation/destinations",
map: dst => ({
...dst,
id: dst.destination,
}),
data: "destinations",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST",
}),
},
destination_rooms: {
map: dstroom => ({
...dstroom,
id: dstroom.room_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}),
data: "rooms",
total: json => {
return json.total;
},
},
registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens",
map: rt => ({
...rt,
id: rt.token,
}),
data: "registration_tokens",
total: json => {
return json.registration_tokens.length;
},
create: params => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params,
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}),
},
};
function filterNullValues(key, value) {
// Filtering out null properties
// to reset user_type from user, it must be null
if (value === null && key !== "user_type") {
return undefined;
}
return value;
}
function getSearchOrder(order) {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider = {
getList: (resource, params) => {
console.log("getList " + resource);
const {
user_id,
name,
guests,
deactivated,
search_term,
destination,
valid,
} = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
user_id: user_id,
search_term: search_term,
name: name,
destination: destination,
guests: guests,
deactivated: deactivated,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
return jsonClient(url).then(({ json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
}));
},
getOne: (resource, params) => {
console.log("getOne " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`).then(
({ json }) => ({
data: res.map(json),
})
);
},
getMany: (resource, params) => {
console.log("getMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
)
).then(responses => ({
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
}));
},
getManyReference: (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const ref = res["reference"](params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
}));
},
update: (resource, params) => {
console.log("update " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
},
updateMany: (resource, params) => {
console.log("updateMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
{
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}
)
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
create: (resource, params) => {
console.log("create " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const create = res["create"](params.data);
const endpoint_url = homeserver + create.endpoint;
return jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
},
createMany: (resource, params) => {
console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
return Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res["create"](params.data);
const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, {
method: cre.method,
body: JSON.stringify(cre.body, filterNullValues),
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
delete: (resource, params) => {
console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if ("delete" in res) {
const del = res["delete"](params);
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
}).then(({ json }) => ({
data: json,
}));
} else {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues),
}).then(({ json }) => ({
data: json,
}));
}
},
deleteMany: (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if ("delete" in res) {
return Promise.all(
params.ids.map(id => {
const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
} else {
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
})
)
).then(responses => ({
data: responses.map(({ json }) => json),
}));
}
},
};
export default dataProvider;

View File

@ -1,20 +1,15 @@
import fetchMock from "jest-fetch-mock";
import dataProvider from "./dataProvider"; import dataProvider from "./dataProvider";
import storage from "../storage";
fetchMock.enableMocks();
beforeEach(() => { beforeEach(() => {
fetchMock.resetMocks(); fetch.resetMocks();
}); });
describe("dataProvider", () => { describe("dataProvider", () => {
storage.setItem("base_url", "http://localhost"); localStorage.setItem("base_url", "http://localhost");
storage.setItem("access_token", "access_token"); localStorage.setItem("access_token", "access_token");
it("fetches all users", async () => { it("fetches all users", async () => {
fetchMock.mockResponseOnce( fetch.mockResponseOnce(
JSON.stringify({ JSON.stringify({
users: [ users: [
{ {
@ -47,13 +42,13 @@ 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");
expect(users.total).toEqual(200); expect(users["total"]).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });
it("fetches one user", async () => { it("fetches one user", async () => {
fetchMock.mockResponseOnce( fetch.mockResponseOnce(
JSON.stringify({ JSON.stringify({
name: "user_id1", name: "user_id1",
password: "user_password", password: "user_password",
@ -76,8 +71,8 @@ describe("dataProvider", () => {
const user = await dataProvider.getOne("users", { id: "user_id1" }); const user = await dataProvider.getOne("users", { id: "user_id1" });
expect(user.data.id).toEqual("user_id1"); expect(user["data"]["id"]).toEqual("user_id1");
expect(user.data.displayname).toEqual("User"); expect(user["data"]["displayname"]).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -1,742 +0,0 @@
import { stringify } from "query-string";
import {
DataProvider,
DeleteParams,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
fetchUtils
} from "react-admin";
import storage from "../storage";
// Adds the access token to all requests
const jsonClient = (url: string, options: Options = {}) => {
const token = storage.getItem("access_token");
console.log("httpClient " + url);
if (token != null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
};
}
return fetchUtils.fetchJson(url, options);
};
const mxcUrlToHttp = (mxcUrl: string) => {
const homeserver = storage.getItem("base_url");
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 Room {
room_id: string;
name?: string;
canonical_alias?: string;
avatar_url?: string;
joined_members: number;
joined_local_members: number;
version: number;
creator: string;
encryption?: string;
federatable: boolean;
public: boolean;
join_rules: "public" | "knock" | "invite" | "private";
guest_access?: "can_join" | "forbidden";
history_visibility: "invited" | "joined" | "shared" | "world_readable";
state_events: number;
room_type?: string;
}
interface RoomState {
age: number;
content: {
alias?: string;
};
event_id: string;
origin_server_ts: number;
room_id: string;
sender: string;
state_key: string;
type: string;
user_id: string;
unsigned: {
age?: number;
};
}
interface ForwardExtremity {
event_id: string;
state_group: number;
depth: number;
received_ts: number;
}
interface EventReport {
id: number;
received_ts: number;
room_id: string;
name: string;
event_id: string;
user_id: string;
reason?: string;
score?: number;
sender: string;
canonical_alias?: string;
}
interface Threepid {
medium: string;
address: string;
added_at: number;
validated_at: number;
}
interface ExternalId {
auth_provider: string;
external_id: string;
}
interface User {
name: string;
displayname?: string;
threepids: Threepid[];
avatar_url?: string;
is_guest: 0 | 1;
admin: 0 | 1;
deactivated: 0 | 1;
erased: boolean;
shadow_banned: 0 | 1;
creation_ts: number;
appservice_id?: string;
consent_server_notice_sent?: string;
consent_version?: string;
consent_ts?: number;
external_ids: ExternalId[];
user_type?: string;
locked: boolean;
}
interface Device {
device_id: string;
display_name?: string;
last_seen_ip?: string;
last_seen_user_agent?: string;
last_seen_ts?: number;
user_id: string;
}
interface Connection {
ip: string;
last_seen: number;
user_agent: string;
}
interface Whois {
user_id: string;
devices: Record<
string,
{
sessions: {
connections: Connection[];
}[];
}
>;
}
interface Pusher {
app_display_name: string;
app_id: string;
data: {
url?: string;
format: string;
};
url: string;
format: string;
device_display_name: string;
profile_tag: string;
kind: string;
lang: string;
pushkey: string;
}
interface UserMedia {
created_ts: number;
last_access_ts?: number;
media_id: string;
media_length: number;
media_type: string;
quarantined_by?: string;
safe_from_quarantine: boolean;
upload_name?: string;
}
interface UserMediaStatistic {
displayname: string;
media_count: number;
media_length: number;
user_id: string;
}
interface RegistrationToken {
token: string;
uses_allowed: number;
pending: number;
completed: number;
expiry_time?: number;
}
interface RaServerNotice {
id: string;
body: string;
}
interface Destination {
destination: string;
retry_last_ts: number;
retry_interval: number;
failure_ts: number;
last_successful_stream_ordering?: number;
}
interface DestinationRoom {
room_id: string;
stream_ordering: number;
}
export interface DeleteMediaParams {
before_ts: string;
size_gt: number;
keep_profiles: boolean;
}
export interface DeleteMediaResult {
deleted_media: Identifier[];
total: number;
}
export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
}
const resourceMap = {
users: {
path: "/_synapse/admin/v2/users",
map: (u: User) => ({
...u,
id: u.name,
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
create: (data: RaRecord) => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`,
body: data,
method: "PUT",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`,
body: { erase: true },
method: "POST",
}),
},
rooms: {
path: "/_synapse/admin/v1/rooms",
map: (r: Room) => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}),
data: "rooms",
total: json => json.total_rooms,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v2/rooms/${params.id}`,
body: { block: false },
}),
},
reports: {
path: "/_synapse/admin/v1/event_reports",
map: (er: EventReport) => ({ ...er }),
data: "event_reports",
total: json => json.total,
},
devices: {
map: (d: Device) => ({
...d,
id: d.device_id,
}),
data: "devices",
total: json => json.total,
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(id)}/devices`,
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(params.previousData.user_id)}/devices/${params.id}`,
}),
},
connections: {
path: "/_synapse/admin/v1/whois",
map: (c: Whois) => ({
...c,
id: c.user_id,
}),
data: "connections",
},
room_members: {
map: (m: string) => ({
id: m,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
total: json => json.total,
},
room_state: {
map: (rs: RoomState) => ({
...rs,
id: rs.event_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/state`,
}),
data: "state",
total: json => json.state.length,
},
pushers: {
map: (p: Pusher) => ({
...p,
id: p.pushkey,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/pushers`,
}),
data: "pushers",
total: json => json.total,
},
joined_rooms: {
map: (jr: string) => ({
id: jr,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/joined_rooms`,
}),
data: "joined_rooms",
total: json => json.total,
},
users_media: {
map: (um: UserMedia) => ({
...um,
id: um.media_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`,
}),
data: "media",
total: json => json.total,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/${storage.getItem("home_server")}/${params.id}`,
}),
},
protect_media: {
map: (pm: UserMedia) => ({ id: pm.media_id }),
create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/protect/${params.media_id}`,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
method: "POST",
}),
},
quarantine_media: {
map: (qm: UserMedia) => ({ id: qm.media_id }),
create: (params: UserMedia) => ({
endpoint: `/_synapse/admin/v1/media/quarantine/${storage.getItem("home_server")}/${params.media_id}`,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${storage.getItem("home_server")}/${params.id}`,
method: "POST",
}),
},
servernotices: {
map: (n: { event_id: string }) => ({ id: n.event_id }),
create: (data: RaServerNotice) => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
content: {
msgtype: "m.text",
body: data.body,
},
},
method: "POST",
}),
},
user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media",
map: (usms: UserMediaStatistic) => ({
...usms,
id: usms.user_id,
}),
data: "users",
total: json => json.total,
},
forward_extremities: {
map: (fe: ForwardExtremity) => ({
...fe,
id: fe.event_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/forward_extremities`,
}),
data: "results",
total: json => json.count,
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/forward_extremities`,
}),
},
room_directory: {
path: "/_matrix/client/r0/publicRooms",
map: (rd: Room) => ({
...rd,
id: rd.room_id,
public: !!rd.public,
guest_access: !!rd.guest_access,
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined,
}),
data: "chunk",
total: json => json.total_room_count_estimate,
create: (params: RaRecord) => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "public" },
method: "PUT",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_matrix/client/r0/directory/list/room/${params.id}`,
body: { visibility: "private" },
method: "PUT",
}),
},
destinations: {
path: "/_synapse/admin/v1/federation/destinations",
map: (dst: Destination) => ({
...dst,
id: dst.destination,
}),
data: "destinations",
total: json => json.total,
delete: params => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${params.id}/reset_connection`,
method: "POST",
}),
},
destination_rooms: {
map: (dstroom: DestinationRoom) => ({
...dstroom,
id: dstroom.room_id,
}),
reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/federation/destinations/${id}/rooms`,
}),
data: "rooms",
total: json => json.total,
},
registration_tokens: {
path: "/_synapse/admin/v1/registration_tokens",
map: (rt: RegistrationToken) => ({
...rt,
id: rt.token,
}),
data: "registration_tokens",
total: json => json.registration_tokens.length,
create: (params: RaRecord) => ({
endpoint: "/_synapse/admin/v1/registration_tokens/new",
body: params,
method: "POST",
}),
delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/registration_tokens/${params.id}`,
}),
},
};
/* eslint-disable @typescript-eslint/no-explicit-any */
function filterNullValues(key: string, value: any) {
// Filtering out null properties
// to reset user_type from user, it must be null
if (value === null && key !== "user_type") {
return undefined;
}
return value;
}
function getSearchOrder(order: "ASC" | "DESC") {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination as PaginationPayload;
const { field, order } = params.sort as SortPayload;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
user_id: user_id,
search_term: search_term,
name: name,
destination: destination,
guests: guests,
deactivated: deactivated,
locked: locked,
valid: valid,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const { json } = await jsonClient(url);
return {
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
},
getOne: async (resource, params) => {
console.log("getOne " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`);
return { data: res.map(json) };
},
getMany: async (resource, params) => {
console.log("getMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homerserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)));
return {
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
};
},
getManyReference: async (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const { json } = await jsonClient(endpoint_url);
return {
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
};
},
update: async (resource, params) => {
console.log("update " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
});
return { data: res.map(json) };
},
updateMany: async (resource, params) => {
console.log("updateMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`), {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
})
);
return { data: responses.map(({ json }) => json) };
},
create: async (resource, params) => {
console.log("create " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const create = res.create(params.data);
const endpoint_url = homeserver + create.endpoint;
const { json } = await jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
});
return { data: res.map(json) };
},
createMany: async (resource: string, params: { ids: Identifier[]; data: RaRecord }) => {
console.log("createMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if (!("create" in res)) throw Error(`Create ${resource} is not allowed`);
const responses = await Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res.create(params.data);
const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, {
method: cre.method,
body: JSON.stringify(cre.body, filterNullValues),
});
})
);
return { data: responses.map(({ json }) => json) };
},
delete: async (resource, params) => {
console.log("delete " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if ("delete" in res) {
const del = res.delete(params);
const endpoint_url = homeserver + del.endpoint;
const { json } = await jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
return { data: json };
} else {
const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues),
});
return { data: json };
}
},
deleteMany: async (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
const res = resourceMap[resource];
if ("delete" in res) {
const responses = await Promise.all(
params.ids.map(id => {
const del = res.delete({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
);
return {
data: responses.map(({ json }) => json),
};
} else {
const endpoint_url = homeserver + res.path;
const responses = await Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
// body: JSON.stringify(params.data, filterNullValues), @FIXME
})
)
);
return { data: responses.map(({ json }) => json) };
}
},
// Custom methods (https://marmelab.com/react-admin/DataProviders.html#adding-custom-methods)
/**
* Delete media by date or size
*
* @link https://matrix-org.github.io/synapse/latest/admin_api/media_admin_api.html#delete-local-media-by-date-or-size
*
* @param before_ts Unix timestamp in milliseconds. Files that were last used before this timestamp will be deleted. It is the timestamp of last access, not the timestamp when the file was created.
* @param size_gt Size of the media in bytes. Files that are larger will be deleted.
* @param keep_profiles Switch to also delete files that are still used in image data (e.g user profile, room avatar). If false these files will be deleted.
* @returns
*/
deleteMedia: async ({ before_ts, size_gt = 0, keep_profiles = true }) => {
const homeserver = storage.getItem("home_server"); // TODO only required for synapse < 1.78.0
const endpoint = `/_synapse/admin/v1/media/${homeserver}/delete?before_ts=${before_ts}&size_gt=${size_gt}&keep_profiles=${keep_profiles}`;
const base_url = storage.getItem("base_url");
const endpoint_url = base_url + endpoint;
const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult;
},
};
export default dataProvider;

48
src/synapse/synapse.js Normal file
View File

@ -0,0 +1,48 @@
import { fetchUtils } from "react-admin";
export const splitMxid = mxid => {
const re =
/^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups;
};
export const isValidBaseUrl = baseUrl =>
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/**
* Resolve the homeserver URL using the well-known lookup
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async domain => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const json = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
return json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
return `https://${domain}`;
}
};
/**
* Get synapse server version
* @param base_url the base URL of the homeserver
* @returns server version
*/
export const getServerVersion = async baseUrl => {
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json.server_version;
};
/**
* Get supported login flows
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async baseUrl => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
return response.json.flows;
};

View File

@ -0,0 +1,31 @@
import { isValidBaseUrl, splitMxid } from "./synapse";
describe("splitMxid", () => {
it("splits valid MXIDs", () =>
expect(splitMxid("@name:domain.tld")).toEqual({
name: "name",
domain: "domain.tld",
}));
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
});
describe("isValidBaseUrl", () => {
it("accepts a http URL", () =>
expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
it("accepts a https URL", () =>
expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
it("accepts a valid URL with port", () =>
expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
it("rejects undefined base URLs", () =>
expect(isValidBaseUrl(undefined)).toBeFalsy());
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
it("rejects non-string base URLs", () =>
expect(isValidBaseUrl({})).toBeFalsy());
it("rejects base URLs without protocol", () =>
expect(isValidBaseUrl("foo.bar")).toBeFalsy());
it("rejects base URLs with path", () =>
expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
it("rejects invalid base URLs", () =>
expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
});

View File

@ -1,23 +0,0 @@
import { isValidBaseUrl, splitMxid } from "./synapse";
describe("splitMxid", () => {
it("splits valid MXIDs", () =>
expect(splitMxid("@name:domain.tld")).toEqual({
name: "name",
domain: "domain.tld",
}));
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
});
describe("isValidBaseUrl", () => {
it("accepts a http URL", () => expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
it("accepts a https URL", () => expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
it("accepts a valid URL with port", () => expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
it("rejects undefined base URLs", () => expect(isValidBaseUrl(undefined)).toBeFalsy());
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
it("rejects non-string base URLs", () => expect(isValidBaseUrl({})).toBeFalsy());
it("rejects base URLs without protocol", () => expect(isValidBaseUrl("foo.bar")).toBeFalsy());
it("rejects base URLs with path", () => expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
it("rejects invalid base URLs", () => expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
});

View File

@ -1,84 +0,0 @@
import { fetchUtils } from "react-admin";
import storage from "../storage";
export const splitMxid = mxid => {
const re = /^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups;
};
export const isValidBaseUrl = baseUrl => /^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/**
* Resolve the homeserver URL using the well-known lookup
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async domain => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
return response.json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
return `https://${domain}`;
}
};
/**
* Get synapse server version
* @param base_url the base URL of the homeserver
* @returns server version
*/
export const getServerVersion = async baseUrl => {
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json.server_version;
};
/** Get supported Matrix features */
export const getSupportedFeatures = async baseUrl => {
const versionUrl = `${baseUrl}/_matrix/client/versions`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json;
};
/**
* Get supported login flows
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async baseUrl => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
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
* @returns full MXID as string
*/
export function generateRandomMxId(): string {
const homeserver = storage.getItem("home_server");
const characters = "0123456789abcdefghijklmnopqrstuvwxyz";
const localpart = Array.from(crypto.getRandomValues(new Uint32Array(8)))
.map(x => characters[x % characters.length])
.join("");
return `@${localpart}:${homeserver}`;
}
/**
* Generate a random user password
* @returns a new random password as string
*/
export function generateRandomPassword(length = 20): string {
const characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$";
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
.map(x => characters[x % characters.length])
.join("");
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["./**/*.ts", "./**/*.tsx"]
}

View File

@ -1,64 +0,0 @@
// prettier-ignore
{
"compilerOptions": {
/* Basic Options */
"target": "ESNext" /* Specify ECMAScript target version */,
"module": "ESNext" /* Specify module code generation */,
"lib": ["DOM", "DOM.Iterable", "ESNext"] /* Specify library files to be included in the compilation. */,
"allowJs": false /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"declaration": true /* Generates corresponding '.d.ts' file. */,
"declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
"isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */
"moduleResolution": "Bundler" /* Specify module resolution strategy */,
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
"types": ["vite/client"], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
"esModuleInterop": false /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
"resolveJsonModule": true,
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": false
},
"include": ["src"],
"references": [{ "path": "./tsconfig.vite.json" }]
}

View File

@ -1,8 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

View File

@ -1,17 +0,0 @@
import { vitePluginVersionMark } from "vite-plugin-version-mark";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
plugins: [
react(),
vitePluginVersionMark({
command: "git describe --tags",
ifMeta: true,
ifLog: true,
ifGlobal: true,
}),
],
});

19415
yarn.lock

File diff suppressed because it is too large Load Diff