Compare commits

6 Commits

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

* Finish upgrade

* fix package.json

* pr fixes

* more pr fixes

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

* some fixes

* update readme

* update readme

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

* Fix README

* Remove mutationMode from Edit

* remove log

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

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

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

* update readme
2024-09-17 14:02:41 +03:00
Aine
66c706532a Fix required fields check on Bulk registration CSV upload, fixes #29 (#32) 2024-09-17 13:17:07 +03:00
Aine
332e98a095 add dev stack 2024-09-17 12:54:29 +03:00
34 changed files with 6379 additions and 8640 deletions

View File

@@ -1,16 +0,0 @@
# Contributing to [etkecc/synapse-admin](https://github.com/etkecc/synapse-admin)
While etke.cc fork is intended to accept more QoL changes and features,
it's good idea to open PR into the upstream repo: [Awesome-Technologies/Synapse-Admin](https://github.com/Awesome-Technologies/synapse-admin).
1. Use the etkecc/synapse-admin **master** branch as your branch upstream: `git checkout master; git pull; git checkout -b my-new-feature`
2. Once your changes are ready, please, open **2** PRs: one from your branch to `Awesome-Technologies/Synapse-Admin` **master**, and another one to `etkecc/synapse-admin` **main**
3. Once PR is accepted in the `etkecc/synapse-admin`, update `README.md` file (either directly in the `main` branch, or via another PR) to add link to the merged PR in the [Fork differences](https://github.com/etkecc/synapse-admin#fork-differences) section
### Why?
The upstream project may not want to accept all the changes, so to ensure they are not lost, we will gladly add them to the etke.cc fork.
Unfortunately, it's challenging to keep changes separated, so to avoid messing upstream and fork changes (e.g., CI changes that should not be pushed to the upstream, as they intended for this fork specifically), there are 2 branches:
* `master` - read-only copy of upstream's master branch to easily sync changes, and use it as base for new PRs
* `main` - fork-own branch with all changes

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,26 +1,19 @@
[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE) # Synapse Admin UI [![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
# Synapse admin ui
This project is built using [react-admin](https://marmelab.com/react-admin/). This project is built using [react-admin](https://marmelab.com/react-admin/).
## Fork differences ## Fork differences
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
user-friendly interface for managing Synapse homeservers.
### Available via CDN ### Available via CDN
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork. On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
### Changes ### Changes
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
user-friendly interface for managing Synapse homeservers.
The following changes are already implemented: The following changes are already implemented:
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1) * [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
@@ -36,9 +29,24 @@ The following changes are already implemented:
* [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (CI only) * [Put the version into manifest.json](https://github.com/Awesome-Technologies/synapse-admin/issues/507) (CI only)
* [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using theme colors) * [Federation page improvements](https://github.com/Awesome-Technologies/synapse-admin/pull/583) (using theme colors)
* [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26) * [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
* [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
* [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
* [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
_the list will be updated as new changes are added_ _the list will be updated as new changes are added_
### Development
`just run-dev` to start the development stack (depending on your system speed, you may want to re-run this command if
user creation fails)
After that open `http://localhost:5173` in your browser, login using the following credentials:
* Login: admin
* Password: admin
* Homeserver URL: http://localhost:8008
## Usage ## Usage
### Supported Synapse ### Supported Synapse

20
docker-compose-dev.yml Normal file
View File

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

View File

@@ -6,8 +6,33 @@ default:
build: __install build: __install
@yarn run build --base=./ @yarn run build --base=./
# run the app in a development mode
run:
@yarn start --host 0.0.0.0
# run dev stack and start the app in a development mode
run-dev:
@echo "Starting the database..."
@docker-compose -f docker-compose-dev.yml up -d postgres
@echo "Starting Synapse..."
@docker-compose -f docker-compose-dev.yml up -d synapse
@echo "Ensure admin user is registered..."
@docker-compose -f docker-compose-dev.yml exec synapse register_new_matrix_user --admin -u admin -p admin -c /config/homeserver.yaml http://localhost:8008 || true
@echo "Starting the app..."
@yarn start --host 0.0.0.0
# stop the dev stack
stop-dev:
@docker-compose -f docker-compose-dev.yml stop
register-user localpart password *admin:
docker-compose exec synapse register_new_matrix_user {{ if admin == "1" {"--admin"} else {"--no-admin"} }} -u {{ localpart }} -p {{ password }} -c /config/homeserver.yaml http://localhost:8008
# run the app in a production mode # run the app in a production mode
run: build run-prod: build
@python -m http.server -d dist 1313 @python -m http.server -d dist 1313
# install the project # install the project

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,6 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
<DialogContentText>{translate(props.confirmContent)}</DialogContentText> <DialogContentText>{translate(props.confirmContent)}</DialogContentText>
<SimpleForm toolbar={false}> <SimpleForm toolbar={false}>
<BooleanInput <BooleanInput
fullWidth
source="block" source="block"
value={block} value={block}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = { const de: SynapseTranslationMessages = {
...formalGermanMessages, ...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Heimserver URL", base_url: "Heimserver URL",
@@ -147,6 +155,7 @@ const de: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Lösche Benutzerdaten", erase: "Lösche Benutzerdaten",
erase_avatar: "Avatar löschen"
}, },
}, },
rooms: { rooms: {

View File

@@ -146,6 +146,7 @@ const en: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Erase user data", erase: "Erase user data",
erase_avatar: "Erase avatar"
}, },
}, },
rooms: { rooms: {

View File

@@ -144,6 +144,7 @@ const fr: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Effacer les données de l'utilisateur", erase: "Effacer les données de l'utilisateur",
erase_avatar: "Effacer l'avatar",
}, },
}, },
rooms: { rooms: {

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

@@ -142,6 +142,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
}; };
action: { action: {
erase: string; erase: string;
erase_avatar: string;
}; };
}; };
rooms: { rooms: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = { const ru: SynapseTranslationMessages = {
...russianMessages, ...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Адрес домашнего сервера", base_url: "Адрес домашнего сервера",
@@ -155,6 +163,7 @@ const ru: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Удалить данные пользователя", erase: "Удалить данные пользователя",
erase_avatar: "Удалить аватар",
}, },
}, },
rooms: { rooms: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = { const zh: SynapseTranslationMessages = {
...chineseMessages, ...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "服务器 URL", base_url: "服务器 URL",
@@ -139,6 +147,7 @@ const zh: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "抹除用户信息", erase: "抹除用户信息",
erase_avatar: "抹掉头像",
}, },
}, },
rooms: { rooms: {

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,9 @@ const RoomTitle = () => {
const RoomShowActions = () => { const RoomShowActions = () => {
const record = useRecordContext(); const record = useRecordContext();
if (!record) {
return null;
}
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />; const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
// FIXME: refresh after (un)publish // FIXME: refresh after (un)publish
return ( return (

View File

@@ -55,6 +55,8 @@ import {
ToolbarClasses, ToolbarClasses,
Identifier, Identifier,
RaRecord, RaRecord,
ImageInput,
ImageField,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -101,26 +103,24 @@ const userFilters = [
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />, <BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
]; ];
const UserPreventSelfDelete: React.FC<{ children: React.ReactNode, ownUserIsSelected: boolean }> = (props) => { const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean }> = props => {
const ownUserIsSelected = props.ownUserIsSelected; const ownUserIsSelected = props.ownUserIsSelected;
const notify = useNotify(); const notify = useNotify();
const translate = useTranslate(); const translate = useTranslate();
const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => { const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
if (ownUserIsSelected) { if (ownUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>) notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
ev.stopPropagation(); ev.stopPropagation();
} }
}; };
return <div onClickCapture={handleDeleteClick}> return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
{props.children}
</div>
}; };
const UserBulkActionButtons = () => { const UserBulkActionButtons = () => {
const record = useListContext(); const record = useListContext();
const [ ownUserIsSelected, setOwnUserIsSelected ] = useState(false); const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
const selectedIds = record.selectedIds; const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
const notify = useNotify(); const notify = useNotify();
@@ -128,19 +128,20 @@ const UserBulkActionButtons = () => {
useEffect(() => { useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId)); setOwnUserIsSelected(selectedIds.includes(ownUserId));
}, [ selectedIds ]); }, [selectedIds]);
return (
return <> <>
<ServerNoticeBulkButton /> <ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BulkDeleteButton <BulkDeleteButton
label="resources.users.action.erase" label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase" confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic" mutationMode="pessimistic"
/> />
</UserPreventSelfDelete> </UserPreventSelfDelete>
</> </>
);
}; };
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => { const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
@@ -204,9 +205,12 @@ const UserEditActions = () => {
}; };
export const UserCreate = (props: CreateProps) => ( export const UserCreate = (props: CreateProps) => (
<Create { ...props} redirect={(resource, id, data) => { <Create
return `users/${id}`; {...props}
}}> redirect={(resource, id, data) => {
return `users/${id}`;
}}
>
<SimpleForm> <SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} /> <TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} /> <TextInput source="displayname" validate={maxLength(256)} />
@@ -237,7 +241,7 @@ const UserTitle = () => {
{translate("resources.users.name", { {translate("resources.users.name", {
smart_count: 1, smart_count: 1,
})}{" "} })}{" "}
{record ? ( record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""} {record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
</span> </span>
); );
}; };
@@ -250,39 +254,52 @@ const UserEditToolbar = () => {
ownUserIsSelected = record.id === ownUserId; ownUserIsSelected = record.id === ownUserId;
} }
return <> return (
<div className={ToolbarClasses.defaultToolbar}> <>
<Toolbar sx={{ justifyContent: "space-between" }}> <div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton /> <SaveButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<DeleteButton /> <DeleteButton />
</UserPreventSelfDelete> </UserPreventSelfDelete>
</Toolbar> </Toolbar>
</div> </div>
</> </>
);
}; };
const UserBooleanInput = (props) => { const UserBooleanInput = props => {
const record = useRecordContext(); const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
const isOwnUser = false;
let ownUserIsSelected = false; let ownUserIsSelected = false;
if (record && (record.id === ownUserId)) { if (record && record.id === ownUserId) {
ownUserIsSelected = true; ownUserIsSelected = true;
} }
return <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}><BooleanInput {...props} disabled={ownUserIsSelected} /></UserPreventSelfDelete> return (
} <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected} />
</UserPreventSelfDelete>
);
};
export const UserEdit = (props: EditProps) => { export const UserEdit = (props: EditProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}> <Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<TabbedForm toolbar={<UserEditToolbar />}> <TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}> <FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px", float: "right" }} /> <AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px" }} />
<TextInput source="id" disabled /> <BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
<ImageInput
source="avatar_file"
label="resources.users.fields.avatar"
accept={{ "image/*": [".png", ".jpg"] }}
>
<ImageField source="src" title="Avatar" />
</ImageInput>
<TextInput source="id" readOnly />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" /> <PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable /> <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />

View File

@@ -101,7 +101,7 @@ describe("authProvider", () => {
}); });
it("should reject if error.status is 401", async () => { it("should reject if error.status is 401", async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined(); await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
}); });
it("should reject if error.status is 403", async () => { it("should reject if error.status is 403", async () => {

View File

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

View File

@@ -1,15 +1,26 @@
import { stringify } from "query-string"; import {
DataProvider,
import { DataProvider, DeleteParams, HttpError, Identifier, Options, RaRecord, fetchUtils } from "react-admin"; DeleteParams,
HttpError,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
UpdateParams,
fetchUtils,
withLifecycleCallbacks,
} from "react-admin";
import storage from "../storage"; import storage from "../storage";
import { returnMXID } from "./synapse";
import { MatrixError, displayError } from "../components/error"; import { MatrixError, displayError } from "../components/error";
// Adds the access token to all requests // Adds the access token to all requests
const jsonClient = async (url: string, options: Options = {}) => { const jsonClient = async (url: string, options: Options = {}) => {
const token = storage.getItem("access_token"); const token = storage.getItem("access_token");
console.log("httpClient " + url); console.log("httpClient " + url);
if (token != null) { if (token !== null) {
options.user = { options.user = {
authenticated: true, authenticated: true,
token: `Bearer ${token}`, token: `Bearer ${token}`,
@@ -22,7 +33,9 @@ const jsonClient = async (url: string, options: Options = {}) => {
const error = err as HttpError; const error = err as HttpError;
const errorStatus = error.status; const errorStatus = error.status;
const errorBody = error.body as MatrixError; const errorBody = error.body as MatrixError;
const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message); const errMsg = !!errorBody?.errcode
? displayError(errorBody.errcode, errorStatus, errorBody.error)
: displayError("M_INVALID", errorStatus, error.message);
return Promise.reject(new HttpError(errMsg, errorStatus, errorBody)); return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
} }
@@ -39,6 +52,10 @@ const mxcUrlToHttp = (mxcUrl: string) => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`; return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
}; };
const filterUndefined = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
};
interface Room { interface Room {
room_id: string; room_id: string;
name?: string; name?: string;
@@ -225,8 +242,19 @@ export interface DeleteMediaResult {
total: number; total: number;
} }
export interface UploadMediaParams {
file: File;
filename: string;
content_type: string;
}
export interface UploadMediaResult {
content_uri: string;
}
export interface SynapseDataProvider extends DataProvider { export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>; deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
} }
const resourceMap = { const resourceMap = {
@@ -234,7 +262,7 @@ const resourceMap = {
path: "/_synapse/admin/v2/users", path: "/_synapse/admin/v2/users",
map: (u: User) => ({ map: (u: User) => ({
...u, ...u,
id: u.name, id: returnMXID(u.name),
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined, avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined,
is_guest: !!u.is_guest, is_guest: !!u.is_guest,
admin: !!u.admin, admin: !!u.admin,
@@ -245,12 +273,12 @@ const resourceMap = {
data: "users", data: "users",
total: json => json.total, total: json => json.total,
create: (data: RaRecord) => ({ create: (data: RaRecord) => ({
endpoint: `/_synapse/admin/v2/users/@${encodeURIComponent(data.id)}:${storage.getItem("home_server")}`, endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(returnMXID(data.id))}`,
body: data, body: data,
method: "PUT", method: "PUT",
}), }),
delete: (params: DeleteParams) => ({ delete: (params: DeleteParams) => ({
endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(params.id)}`, endpoint: `/_synapse/admin/v1/deactivate/${encodeURIComponent(returnMXID(params.id))}`,
body: { erase: true }, body: { erase: true },
method: "POST", method: "POST",
}), }),
@@ -349,7 +377,7 @@ const resourceMap = {
id: um.media_id, id: um.media_id,
}), }),
reference: (id: Identifier) => ({ reference: (id: Identifier) => ({
endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(id)}/media`, endpoint: `/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`,
}), }),
data: "media", data: "media",
total: json => json.total, total: json => json.total,
@@ -384,7 +412,7 @@ const resourceMap = {
create: (data: RaServerNotice) => ({ create: (data: RaServerNotice) => ({
endpoint: "/_synapse/admin/v1/send_server_notice", endpoint: "/_synapse/admin/v1/send_server_notice",
body: { body: {
user_id: data.id, user_id: returnMXID(data.id),
content: { content: {
msgtype: "m.text", msgtype: "m.text",
body: data.body, body: data.body,
@@ -397,7 +425,7 @@ const resourceMap = {
path: "/_synapse/admin/v1/statistics/users/media", path: "/_synapse/admin/v1/statistics/users/media",
map: (usms: UserMediaStatistic) => ({ map: (usms: UserMediaStatistic) => ({
...usms, ...usms,
id: usms.user_id, id: returnMXID(usms.user_id),
}), }),
data: "users", data: "users",
total: json => json.total, total: json => json.total,
@@ -499,12 +527,12 @@ function getSearchOrder(order: "ASC" | "DESC") {
} }
} }
const dataProvider: SynapseDataProvider = { const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
console.log("getList " + resource); console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter; const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination; const { page, perPage } = params.pagination as PaginationPayload;
const { field, order } = params.sort; const { field, order } = params.sort as SortPayload;
const from = (page - 1) * perPage; const from = (page - 1) * perPage;
const query = { const query = {
from: from, from: from,
@@ -526,7 +554,7 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`; const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url); const { json } = await jsonClient(url);
return { return {
@@ -580,7 +608,7 @@ const dataProvider: SynapseDataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const ref = res.reference(params.id); const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`; const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(endpoint_url); const { json } = await jsonClient(endpoint_url);
return { return {
@@ -741,6 +769,46 @@ const dataProvider: SynapseDataProvider = {
const { json } = await jsonClient(endpoint_url, { method: "POST" }); const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult; return json as DeleteMediaResult;
}, },
uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
const base_url = storage.getItem("base_url");
const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
const { json } = await jsonClient(`${uploadMediaURL}?filename=${filename}`, {
method: "POST",
body: file,
headers: new Headers({
Accept: "application/json",
"Content-Type": content_type,
}) as Headers,
});
return json as UploadMediaResult;
},
}; };
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
{
resource: "users",
beforeUpdate: async (params: UpdateParams<any>, dataProvider: DataProvider) => {
const avatarFile = params.data.avatar_file?.rawFile;
const avatarErase = params.data.avatar_erase;
if (avatarErase) {
params.data.avatar_url = "";
return params;
}
if (avatarFile instanceof File) {
const reponse = await dataProvider.uploadMedia({
file: avatarFile,
filename: params.data.avatar_file.title,
content_type: params.data.avatar_file.rawFile.type,
});
params.data.avatar_url = reponse.content_uri;
}
return params;
},
},
]);
export default dataProvider; export default dataProvider;

View File

@@ -1,4 +1,4 @@
import { fetchUtils } from "react-admin"; import { Identifier, fetchUtils } from "react-admin";
import storage from "../storage"; import storage from "../storage";
@@ -72,6 +72,26 @@ export function generateRandomMxId(): string {
return `@${localpart}:${homeserver}`; return `@${localpart}:${homeserver}`;
} }
/**
* Return the full MXID from an arbitrary input
* @param input the input string
* @returns full MXID as string
*/
export function returnMXID(input: string | Identifier): string {
const homeserver = storage.getItem("home_server");
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
const mxidPattern = /^@[^@:]+:[^@:]+$/;
if (typeof input === 'string' && mxidPattern.test(input)) {
return input; // Already a valid MXID
}
// If input is not a valid MXID, assume it's a localpart and construct the MXID
const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input;
return `@${localpart}:${homeserver}`;
}
/** /**
* Generate a random user password * Generate a random user password
* @returns a new random password as string * @returns a new random password as string

191
testdata/synapse/homeserver.yaml vendored Normal file
View File

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

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

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

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

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

View File

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

14257
yarn.lock

File diff suppressed because it is too large Load Diff