Compare commits

..

No commits in common. "b112689b8c796686872cb573ad2a582dcaacf3b5" and "7deb9bcf7e54cd4c4e634910c5d14f90ab85f405" have entirely different histories.

55 changed files with 10874 additions and 11297 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*

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

@ -1,5 +1,4 @@
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:
@ -14,50 +13,39 @@ on:
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@v5 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

@ -11,19 +11,16 @@ jobs:
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.6.0 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@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 - 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 +0,0 @@
.yarn

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
}

Binary file not shown.

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.1.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

@ -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,83 +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 test` to run all style, lint and unit tests
- Use `yarn fix` to fix the coding style - Use `yarn fix` to fix the coding style

View file

@ -1,3 +1,5 @@
version: "3"
services: services:
synapse-admin: synapse-admin:
container_name: synapse-admin container_name: synapse-admin
@ -11,11 +13,14 @@ services:
# 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"
# - BASE_PATH="/synapse-admin" # default is .
# - 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.jsx"></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,8 +1,7 @@
{ {
"name": "synapse-admin", "name": "synapse-admin",
"version": "0.10.1", "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,87 +9,48 @@
"type": "git", "type": "git",
"url": "https://github.com/Awesome-Technologies/synapse-admin" "url": "https://github.com/Awesome-Technologies/synapse-admin"
}, },
"packageManager": "yarn@4.1.1",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.4",
"@babel/preset-env": "^7.24.4",
"@babel/preset-react": "^7.24.1",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.2", "@testing-library/react": "^15.0.2",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@vitejs/plugin-react": "^4.0.0",
"babel-jest": "^29.7.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"prettier": "^3.2.5", "prettier": "^3.2.5"
"react-test-renderer": "^18.2.0",
"vite": "^5.0.0",
"vite-plugin-version-mark": "^0.0.13"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@haleos/ra-language-german": "^1.0.0",
"@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^5.15.15", "@mui/icons-material": "^5.15.15",
"@mui/material": "^5.15.15", "@mui/material": "^5.15.15",
"history": "^5.1.0", "@mui/styles": "^5.15.15",
"lodash": "^4.17.21",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"query-string": "^7.1.1", "ra-language-chinese": "^2.0.10",
"ra-core": "^4.16.15",
"ra-i18n-polyglot": "^4.16.15",
"ra-language-english": "^4.16.15",
"ra-language-farsi": "^4.2.0",
"ra-language-french": "^4.16.15", "ra-language-french": "^4.16.15",
"ra-language-german": "^3.13.4",
"ra-language-italian": "^3.13.1", "ra-language-italian": "^3.13.1",
"ra-language-farsi": "^4.2.0",
"react": "^18.0.0", "react": "^18.0.0",
"react-admin": "^4.16.15", "react-admin": "^4.16.15",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-hook-form": "^7.43.9", "react-scripts": "^5.0.1"
"react-is": "^18.2.0",
"react-query": "^3.32.1",
"react-router": "^6.1.0",
"react-router-dom": "^6.1.0"
}, },
"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",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
"fix:code": "yarn test:lint --fix", "fix:code": "yarn test:lint --fix",
"fix": "yarn fix:code && yarn fix:other", "fix": "yarn fix:code && yarn fix:other",
"prettier": "prettier \"**/*.{js,jsx,json,md,scss,yaml,yml}\"", "prettier": "prettier --ignore-path .gitignore \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
"test:code": "jest", "test:code": "react-scripts test",
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .", "test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
"test:style": "yarn prettier --check", "test:style": "yarn prettier --list-different",
"test": "yarn test:style && yarn test:lint && yarn test:code" "test": "yarn test:style && yarn test:lint && yarn test:code",
}, "eject": "react-scripts eject"
"babel": {
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
]
]
}, },
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"
}, },
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js"
]
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

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>

View file

@ -6,7 +6,6 @@ import {
resolveBrowserLocale, resolveBrowserLocale,
} from "react-admin"; } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot"; import polyglotI18nProvider from "ra-i18n-polyglot";
import merge from "lodash/merge";
import authProvider from "./synapse/authProvider"; import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider from "./synapse/dataProvider";
import users from "./components/users"; import users from "./components/users";
@ -34,17 +33,8 @@ const messages = {
zh: chineseMessages, zh: chineseMessages,
}; };
const i18nProvider = polyglotI18nProvider( const i18nProvider = polyglotI18nProvider(
locale => locale => (messages[locale] ? messages[locale] : messages.en),
messages[locale] ? merge({}, messages.en, messages[locale]) : messages.en, resolveBrowserLocale()
resolveBrowserLocale(),
[
{ locale: "en", name: "English" },
{ locale: "de", name: "Deutsch" },
{ locale: "fr", name: "Français" },
{ locale: "it", name: "Italiano" },
{ locale: "fa", name: "Persian(فارسی)" },
{ locale: "zh", name: "简体中文" },
]
); );
const App = () => ( const App = () => (

View file

@ -1,5 +0,0 @@
import { createContext, useContext } from "react";
export const AppContext = createContext({});
export const useAppContext = () => useContext(AppContext);

View file

@ -15,7 +15,6 @@ import {
useRecordContext, useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { MXCField } from "./media";
import PageviewIcon from "@mui/icons-material/Pageview"; import PageviewIcon from "@mui/icons-material/Pageview";
import ReportIcon from "@mui/icons-material/Warning"; import ReportIcon from "@mui/icons-material/Warning";
import ViewListIcon from "@mui/icons-material/ViewList"; import ViewListIcon from "@mui/icons-material/ViewList";
@ -90,8 +89,6 @@ export const ReportShow = props => {
<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" />

View file

@ -10,7 +10,6 @@ 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 {
@ -22,15 +21,13 @@ import {
CircularProgress, CircularProgress,
MenuItem, MenuItem,
Select, Select,
TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import { useAppContext } from "../AppContext";
import { import {
getServerVersion, getServerVersion,
getSupportedFeatures,
getSupportedLoginFlows, getSupportedLoginFlows,
getWellKnownUrl, getWellKnownUrl,
isValidBaseUrl, isValidBaseUrl,
@ -40,7 +37,7 @@ import {
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)",
@ -49,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",
}, },
@ -63,49 +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 const base_url = localStorage.getItem("base_url");
? restrictBaseUrl const cfg_base_url = process.env.REACT_APP_SERVER;
: localStorage.getItem("base_url");
const [ssoBaseUrl, setSSOBaseUrl] = useState(""); const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href); const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
@ -143,6 +127,20 @@ 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");
@ -181,27 +179,17 @@ 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)
@ -212,14 +200,6 @@ const LoginPage = () => {
) )
.catch(() => setServerVersion("")); .catch(() => setServerVersion(""));
getSupportedFeatures(formData.base_url)
.then(features =>
setMatrixVersions(
`${translate("synapseadmin.auth.supports_specs")} ${features.versions.join(", ")}`
)
)
.catch(() => setMatrixVersions(""));
// Set SSO Url // Set SSO Url
getSupportedLoginFlows(formData.base_url) getSupportedLoginFlows(formData.base_url)
.then(loginFlows => { .then(loginFlows => {
@ -231,7 +211,7 @@ const LoginPage = () => {
setSSOBaseUrl(supportSSO ? formData.base_url : ""); setSSOBaseUrl(supportSSO ? formData.base_url : "");
}) })
.catch(() => setSSOBaseUrl("")); .catch(() => setSSOBaseUrl(""));
}, [formData.base_url, form]); }, [formData.base_url]);
return ( return (
<> <>
@ -239,56 +219,49 @@ const LoginPage = () => {
<TextInput <TextInput
autoFocus autoFocus
name="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 fullWidth
className="input"
validate={required()} validate={required()}
/> />
</Box> </Box>
<Box> <Box>
<PasswordInput <PasswordInput
name="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 fullWidth
className="input"
validate={required()} validate={required()}
/> />
</Box> </Box>
<Box> <Box>
<TextInput <TextInput
name="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}
readOnly={allowSingleBaseUrl}
resettable={allowAnyBaseUrl}
fullWidth fullWidth
className="input"
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 <Form
defaultValues={{ base_url: base_url }} defaultValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
mode="onTouched" mode="onTouched"
> >
@ -307,16 +280,19 @@ 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> <FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />} {formDataProps => <UserData {...formDataProps} />}

View file

@ -1,71 +1,14 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { render } from "@testing-library/react";
import { AdminContext } from "react-admin"; import { AdminContext } from "react-admin";
import LoginPage from "./LoginPage"; import LoginPage from "./LoginPage";
import { AppContext } from "../AppContext";
describe("LoginForm", () => { describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => { it("renders", () => {
render( render(
<AdminContext> <AdminContext>
<LoginPage /> <LoginPage />
</AdminContext> </AdminContext>
); );
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
const baseUrlInput = screen.getByRole("textbox", {
name: "synapseadmin.auth.base_url",
});
expect(baseUrlInput.className.split(" ")).not.toContain("Mui-readOnly");
screen.getByRole("button", { name: "ra.auth.sign_in" });
});
it("renders with single restricted homeserver", () => {
render(
<AppContext.Provider
value={{ restrictBaseUrl: "https://matrix.example.com" }}
>
<AdminContext>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
const baseUrlInput = screen.getByRole("textbox", {
name: "synapseadmin.auth.base_url",
});
expect(baseUrlInput.className.split(" ")).toContain("Mui-readOnly");
screen.getByRole("button", { name: "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>
<LoginPage />
</AdminContext>
</AppContext.Provider>
);
screen.getByText("synapseadmin.auth.welcome");
screen.getByRole("combobox", { name: "" });
screen.getByRole("textbox", { name: "ra.auth.username" });
screen.getByText("ra.auth.password");
screen.getByRole("combobox", { name: "synapseadmin.auth.base_url" });
screen.getByRole("button", { name: "ra.auth.sign_in" });
}); });
}); });

View file

@ -1,5 +1,4 @@
import React, { useState } from "react"; import React, { useState } from "react";
import get from "lodash/get";
import { import {
BooleanInput, BooleanInput,
Button, Button,
@ -15,12 +14,10 @@ import {
useRefresh, useRefresh,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom";
import BlockIcon from "@mui/icons-material/Block"; import BlockIcon from "@mui/icons-material/Block";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import { import {
Box,
Dialog, Dialog,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
@ -30,9 +27,7 @@ import {
import IconCancel from "@mui/icons-material/Cancel"; import IconCancel from "@mui/icons-material/Cancel";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockOpenIcon from "@mui/icons-material/LockOpen";
import FileOpenIcon from "@mui/icons-material/FileOpen";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
import { getMediaUrl } from "../synapse/synapse";
const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => { const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
@ -338,49 +333,3 @@ export const QuarantineMediaButton = props => {
</> </>
); );
}; };
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 = localStorage.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

@ -51,11 +51,7 @@ import { Link } from "react-router-dom";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices"; import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices"; import { DeviceRemoveButton } from "./devices";
import { import { ProtectMediaButton, QuarantineMediaButton } from "./media";
MediaIDField,
ProtectMediaButton,
QuarantineMediaButton,
} from "./media";
const choices_medium = [ const choices_medium = [
{ id: "email", name: "resources.users.email" }, { id: "email", name: "resources.users.email" },
@ -453,13 +449,13 @@ export const UserEdit = props => {
sort={{ field: "created_ts", order: "DESC" }} sort={{ field: "created_ts", order: "DESC" }}
> >
<Datagrid style={{ width: "100%" }}> <Datagrid style={{ width: "100%" }}>
<MediaIDField source="media_id" />
<DateField source="created_ts" showTime options={date_format} /> <DateField source="created_ts" showTime options={date_format} />
<DateField <DateField
source="last_access_ts" source="last_access_ts"
showTime showTime
options={date_format} options={date_format}
/> />
<TextField source="media_id" />
<NumberField source="media_length" /> <NumberField source="media_length" />
<TextField source="media_type" /> <TextField source="media_type" />
<TextField source="upload_name" /> <TextField source="upload_name" />

View file

@ -1,13 +1,12 @@
import { formalGermanMessages } from "@haleos/ra-language-german"; import germanMessages from "ra-language-german";
const de = { const de = {
...formalGermanMessages, ...germanMessages,
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",
@ -208,9 +207,6 @@ const de = {
format: "Nachrichtenformat", format: "Nachrichtenformat",
formatted_body: "Formatierter Nachrichteninhalt", formatted_body: "Formatierter Nachrichteninhalt",
algorithm: "Verschlüsselungsalgorithmus", algorithm: "Verschlüsselungsalgorithmus",
info: {
mimetype: "Typ",
},
}, },
}, },
}, },
@ -259,9 +255,6 @@ const de = {
created_ts: "Erstellt", created_ts: "Erstellt",
last_access_ts: "Letzter Zugriff", last_access_ts: "Letzter Zugriff",
}, },
action: {
open: "Mediendatei in neuem Fenster öffnen",
},
}, },
delete_media: { delete_media: {
name: "Medien", name: "Medien",
@ -395,5 +388,37 @@ const de = {
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

@ -7,7 +7,6 @@ const en = {
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",
@ -205,10 +204,6 @@ const en = {
format: "format", format: "format",
formatted_body: "formatted content", formatted_body: "formatted content",
algorithm: "algorithm", algorithm: "algorithm",
url: "URL",
info: {
mimetype: "Type",
},
}, },
}, },
}, },
@ -257,9 +252,6 @@ const en = {
created_ts: "Created", created_ts: "Created",
last_access_ts: "Last access", last_access_ts: "Last access",
}, },
action: {
open: "Open media file in new window",
},
}, },
delete_media: { delete_media: {
name: "Media", name: "Media",
@ -379,6 +371,7 @@ const en = {
}, },
action: { reconnect: "Reconnect" }, action: { reconnect: "Reconnect" },
}, },
},
registration_tokens: { registration_tokens: {
name: "Registration tokens", name: "Registration tokens",
fields: { fields: {
@ -392,6 +385,5 @@ const en = {
}, },
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

@ -364,6 +364,7 @@ const fa = {
}, },
action: { reconnect: "دوباره وصل شوید" }, action: { reconnect: "دوباره وصل شوید" },
}, },
},
registration_tokens: { registration_tokens: {
name: "توکن های ثبت نام", name: "توکن های ثبت نام",
fields: { fields: {
@ -377,6 +378,5 @@ const fa = {
}, },
helper: { length: "طول توکن در صورت عدم ارائه توکن." }, helper: { length: "طول توکن در صورت عدم ارائه توکن." },
}, },
},
}; };
export default fa; export default fa;

View file

@ -356,6 +356,7 @@ const fr = {
send_failure: "Une erreur s'est produite", send_failure: "Une erreur s'est produite",
}, },
}, },
},
registration_tokens: { registration_tokens: {
name: "Jetons d'inscription", name: "Jetons d'inscription",
fields: { fields: {
@ -372,6 +373,5 @@ const fr = {
"Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié", "Longueur du jeton généré aléatoirement si aucun jeton n'est pas spécifié",
}, },
}, },
},
}; };
export default fr; export default fr;

View file

@ -367,6 +367,7 @@ const it = {
}, },
action: { reconnect: "Riconnetti" }, action: { reconnect: "Riconnetti" },
}, },
},
registration_tokens: { registration_tokens: {
name: "Token di registrazione", name: "Token di registrazione",
fields: { fields: {
@ -380,6 +381,5 @@ const it = {
}, },
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,4 +1,4 @@
import chineseMessages from "@haxqer/ra-language-chinese"; import chineseMessages from "ra-language-chinese";
const zh = { const zh = {
...chineseMessages, ...chineseMessages,

View file

@ -1,17 +1,9 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import { AppContext } from "./AppContext";
fetch("config.json") createRoot(document.getElementById("root")).render(
.then(res => res.json())
.then(props =>
createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<AppContext.Provider value={props}>
<App /> <App />
</AppContext.Provider>
</React.StrictMode> </React.StrictMode>
) );
);

View file

@ -2,7 +2,10 @@ import { fetchUtils } from "react-admin";
const authProvider = { const authProvider = {
// called when the user attempts to log in // called when the user attempts to log in
login: async ({ base_url, username, password, loginToken }) => { login: ({ base_url, username, password, loginToken }) => {
// force homeserver for protection in case the form is manipulated
base_url = process.env.REACT_APP_SERVER || base_url;
console.log("login "); console.log("login ");
const options = { const options = {
method: "POST", method: "POST",
@ -35,14 +38,15 @@ const authProvider = {
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 }) => {
localStorage.setItem("home_server", json.home_server); localStorage.setItem("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id); localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token); localStorage.setItem("access_token", json.access_token);
localStorage.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 = const logout_api_url =
@ -58,9 +62,11 @@ const 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 }) => {
localStorage.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 }) => { checkError: ({ status }) => {

View file

@ -1,135 +0,0 @@
import authProvider from "./authProvider";
describe("authProvider", () => {
beforeEach(() => {
fetch.resetMocks();
localStorage.clear();
});
describe("login", () => {
it("should successfully login with username and password", async () => {
fetch.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret = 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: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","user":"@user:example.com","password":"secret"}',
headers: new Headers({
Accept: ["application/json"],
"Content-Type": ["application/json"],
}),
method: "POST",
}
);
expect(localStorage.getItem("base_url")).toEqual("http://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar");
expect(localStorage.getItem("device_id")).toEqual("some_device");
});
});
it("should successfully login with token", async () => {
fetch.once(
JSON.stringify({
home_server: "example.com",
user_id: "@user:example.com",
access_token: "foobar",
device_id: "some_device",
})
);
const ret = await authProvider.login({
base_url: "https://example.com/",
loginToken: "login_token",
});
expect(ret).toBe(undefined);
expect(fetch).toBeCalledWith(
"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(localStorage.getItem("base_url")).toEqual("https://example.com");
expect(localStorage.getItem("user_id")).toEqual("@user:example.com");
expect(localStorage.getItem("access_token")).toEqual("foobar");
expect(localStorage.getItem("device_id")).toEqual("some_device");
});
describe("logout", () => {
it("should remove the access_token from localStorage", async () => {
localStorage.setItem("base_url", "example.com");
localStorage.setItem("access_token", "foo");
fetch.mockResponse(JSON.stringify({}));
await authProvider.logout();
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(localStorage.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 () => {
localStorage.setItem("access_token", "foobar");
await expect(authProvider.checkAuth({})).resolves.toBeUndefined();
});
});
describe("getPermissions", () => {
it("should do nothing", async () => {
await expect(authProvider.getPermissions()).resolves.toBeUndefined();
});
});
});

View file

@ -348,7 +348,7 @@ function getSearchOrder(order) {
} }
const dataProvider = { const dataProvider = {
getList: async (resource, params) => { getList: (resource, params) => {
console.log("getList " + resource); console.log("getList " + resource);
const { const {
user_id, user_id,
@ -383,14 +383,13 @@ const dataProvider = {
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`; const url = `${endpoint_url}?${stringify(query)}`;
const { json } = await jsonClient(url); return jsonClient(url).then(({ json }) => ({
return {
data: json[res.data].map(res.map), data: json[res.data].map(res.map),
total: res.total(json, from, perPage), total: res.total(json, from, perPage),
}; }));
}, },
getOne: async (resource, params) => { getOne: (resource, params) => {
console.log("getOne " + resource); console.log("getOne " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -398,13 +397,14 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const { json } = await jsonClient( return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`).then(
`${endpoint_url}/${encodeURIComponent(params.id)}` ({ json }) => ({
data: res.map(json),
})
); );
return { data: res.map(json) };
}, },
getMany: async (resource, params) => { getMany: (resource, params) => {
console.log("getMany " + resource); console.log("getMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -412,18 +412,17 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const responses = await Promise.all( return Promise.all(
params.ids.map(id => params.ids.map(id =>
jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`) jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`)
) )
); ).then(responses => ({
return {
data: responses.map(({ json }) => res.map(json)), data: responses.map(({ json }) => res.map(json)),
total: responses.length, total: responses.length,
}; }));
}, },
getManyReference: async (resource, params) => { getManyReference: (resource, params) => {
console.log("getManyReference " + resource); console.log("getManyReference " + resource);
const { page, perPage } = params.pagination; const { page, perPage } = params.pagination;
const { field, order } = params.sort; const { field, order } = params.sort;
@ -443,14 +442,13 @@ const dataProvider = {
const ref = res["reference"](params.id); const ref = res["reference"](params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`; const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const { json } = await jsonClient(endpoint_url); return jsonClient(endpoint_url).then(({ headers, json }) => ({
return {
data: json[res.data].map(res.map), data: json[res.data].map(res.map),
total: res.total(json, from, perPage), total: res.total(json, from, perPage),
}; }));
}, },
update: async (resource, params) => { update: (resource, params) => {
console.log("update " + resource); console.log("update " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -458,17 +456,15 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const { json } = await jsonClient( return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
`${endpoint_url}/${encodeURIComponent(params.id)}`,
{
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
} }).then(({ json }) => ({
); data: res.map(json),
return { data: res.map(json) }; }));
}, },
updateMany: async (resource, params) => { updateMany: (resource, params) => {
console.log("updateMany " + resource); console.log("updateMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -476,7 +472,7 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const responses = await Promise.all( return Promise.all(
params.ids.map( params.ids.map(
id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`), id => jsonClient(`${endpoint_url}/${encodeURIComponent(id)}`),
{ {
@ -484,11 +480,12 @@ const dataProvider = {
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
} }
) )
); ).then(responses => ({
return { data: responses.map(({ json }) => json) }; data: responses.map(({ json }) => json),
}));
}, },
create: async (resource, params) => { create: (resource, params) => {
console.log("create " + resource); console.log("create " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -498,14 +495,15 @@ const dataProvider = {
const create = res["create"](params.data); const create = res["create"](params.data);
const endpoint_url = homeserver + create.endpoint; const endpoint_url = homeserver + create.endpoint;
const { json } = await jsonClient(endpoint_url, { return jsonClient(endpoint_url, {
method: create.method, method: create.method,
body: JSON.stringify(create.body, filterNullValues), body: JSON.stringify(create.body, filterNullValues),
}); }).then(({ json }) => ({
return { data: res.map(json) }; data: res.map(json),
}));
}, },
createMany: async (resource, params) => { createMany: (resource, params) => {
console.log("createMany " + resource); console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -513,7 +511,7 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject(); if (!("create" in res)) return Promise.reject();
const responses = await Promise.all( return Promise.all(
params.ids.map(id => { params.ids.map(id => {
params.data.id = id; params.data.id = id;
const cre = res["create"](params.data); const cre = res["create"](params.data);
@ -523,11 +521,12 @@ const dataProvider = {
body: JSON.stringify(cre.body, filterNullValues), body: JSON.stringify(cre.body, filterNullValues),
}); });
}) })
); ).then(responses => ({
return { data: responses.map(({ json }) => json) }; data: responses.map(({ json }) => json),
}));
}, },
delete: async (resource, params) => { delete: (resource, params) => {
console.log("delete " + resource); console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -537,22 +536,24 @@ const dataProvider = {
if ("delete" in res) { if ("delete" in res) {
const del = res["delete"](params); const del = res["delete"](params);
const endpoint_url = homeserver + del.endpoint; const endpoint_url = homeserver + del.endpoint;
const { json } = await jsonClient(endpoint_url, { return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE", method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null, body: "body" in del ? JSON.stringify(del.body) : null,
}); }).then(({ json }) => ({
return { data: json }; data: json,
}));
} else { } else {
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const { json } = await jsonClient(`${endpoint_url}/${params.id}`, { return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues), body: JSON.stringify(params.previousData, filterNullValues),
}); }).then(({ json }) => ({
return { data: json }; data: json,
}));
} }
}, },
deleteMany: async (resource, params) => { deleteMany: (resource, params) => {
console.log("deleteMany " + resource); console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -560,7 +561,7 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
if ("delete" in res) { if ("delete" in res) {
const responses = await Promise.all( return Promise.all(
params.ids.map(id => { params.ids.map(id => {
const del = res["delete"]({ ...params, id: id }); const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint; const endpoint_url = homeserver + del.endpoint;
@ -569,21 +570,21 @@ const dataProvider = {
body: "body" in del ? JSON.stringify(del.body) : null, body: "body" in del ? JSON.stringify(del.body) : null,
}); });
}) })
); ).then(responses => ({
return {
data: responses.map(({ json }) => json), data: responses.map(({ json }) => json),
}; }));
} else { } else {
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const responses = await Promise.all( return Promise.all(
params.ids.map(id => params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, { jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
}) })
) )
); ).then(responses => ({
return { data: responses.map(({ json }) => json) }; data: responses.map(({ json }) => json),
}));
} }
}, },
}; };

View file

@ -36,13 +36,6 @@ export const getServerVersion = async baseUrl => {
return response.json.server_version; 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 * Get supported login flows
* @param baseUrl the base URL of the homeserver * @param baseUrl the base URL of the homeserver
@ -53,8 +46,3 @@ export const getSupportedLoginFlows = async baseUrl => {
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" }); const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
return response.json.flows; return response.json.flows;
}; };
export const getMediaUrl = media_id => {
const baseUrl = localStorage.getItem("base_url");
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
};

View file

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

20048
yarn.lock

File diff suppressed because it is too large Load diff