Compare commits

8 Commits

Author SHA1 Message Date
Borislav Pantaleev
02bee92806 Fix missing QueryClient (#47) 2024-09-26 21:42:09 +00:00
Aine
b7f8e03894 update README 2024-09-25 19:26:26 +03:00
Aine
59bb4b4183 update README 2024-09-25 19:10:43 +03:00
Aine
bb53d53692 Add Contact support button (#45) 2024-09-25 19:09:58 +03:00
Aine
52a2f1c936 reword managed user error 2024-09-24 21:58:24 +03:00
Aine
e328380c77 Restrict actions on specific users (#42)
* Restrict actions on specific users

* update readme
2024-09-24 13:25:29 +03:00
dependabot[bot]
a277ded227 Bump rollup from 4.22.0 to 4.22.4 (#41)
Bumps [rollup](https://github.com/rollup/rollup) from 4.22.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.22.0...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  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-24 10:18:09 +03:00
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
30 changed files with 6167 additions and 8644 deletions

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

135
README.md
View File

@@ -1,7 +1,32 @@
# 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) # 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)
![Screenshots](./screenshots.jpg)
This project is built using [react-admin](https://marmelab.com/react-admin/). This project is built using [react-admin](https://marmelab.com/react-admin/).
<!-- vim-markdown-toc GFM -->
* [Fork differences](#fork-differences)
* [Available via CDN](#available-via-cdn)
* [Changes](#changes)
* [Development](#development)
* [Configuration](#configuration)
* [Restricting available homeserver](#restricting-available-homeserver)
* [Protecting appservice managed users](#protecting-appservice-managed-users)
* [Providing support URL](#providing-support-url)
* [Usage](#usage)
* [Supported Synapse](#supported-synapse)
* [Prerequisites](#prerequisites)
* [Use without install](#use-without-install)
* [Step-By-Step install](#step-by-step-install)
* [Steps for 1)](#steps-for-1)
* [Steps for 2)](#steps-for-2)
* [Steps for 3)](#steps-for-3)
* [Serving Synapse-Admin on a different path](#serving-synapse-admin-on-a-different-path)
* [Development](#development-1)
<!-- vim-markdown-toc -->
## Fork differences ## Fork differences
With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
@@ -32,6 +57,9 @@ The following changes are already implemented:
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32) * [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) * [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) * [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
* [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
_the list will be updated as new changes are added_ _the list will be updated as new changes are added_
@@ -46,6 +74,64 @@ After that open `http://localhost:5173` in your browser, login using the followi
* Password: admin * Password: admin
* Homeserver URL: http://localhost:8008 * Homeserver URL: http://localhost:8008
## Configuration
You can use `config.json` file to configure synapse-admin
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
...
```
### 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"]
}
```
### Protecting appservice managed users
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
you can specify the list of MXIDs (regexp) that should be prohibited from any changes, except display name and avatar.
Example for [mautrix-telegram](https://github.com/mautrix/telegram)
```json
{
"asManagedUsers": ["^@telegram_[a-zA-Z0-9]+:example\\.com$"]
}
```
### Providing support URL
Synapse-Admin provides a support link in the main menu - `Contact support`. By default, the link points to the GitHub issues page of the project. You can change this link by providing a `supportURL` in the `config.json`.
```json
{
"supportURL": "https://example.com/support"
}
```
## Usage ## Usage
### Supported Synapse ### Supported Synapse
@@ -69,7 +155,7 @@ See also [Synapse administration endpoints](https://element-hq.github.io/synapse
### Use without install ### Use without install
You can use the current version of Synapse Admin without own installation direct You can use the current version of Synapse Admin without own installation direct
via [GitHub Pages](https://awesome-technologies.github.io/synapse-admin/). via [admin.etke.cc](https://admin.etke.cc).
**Note:** **Note:**
If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser. If you want to use the deployment, you have to make sure that the admin endpoints (`/_synapse/admin`) are accessible for your browser.
@@ -88,22 +174,22 @@ You have three options:
- make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do) - make sure you have a webserver installed that can serve static files (any webserver like nginx or apache will do)
- configure a vhost for synapse admin on your webserver - configure a vhost for synapse admin on your webserver
- download the .tar.gz from the latest release: https://github.com/Awesome-Technologies/synapse-admin/releases/latest - download the .tar.gz [from the latest release](https://github.com/etkecc/synapse-admin/releases/latest)
- unpack the .tar.gz - unpack the .tar.gz
- move or symlink the `synapse-admin-x.x.x` into your vhosts root dir - move or symlink the `synapse-admin` into your vhosts root dir
- open the url of the vhost in your browser - open the url of the vhost in your browser
#### Steps for 2) #### Steps for 2)
- make sure you have installed the following: git, yarn, nodejs - make sure you have installed the following: git, yarn, nodejs
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git` - download the source code: `git clone https://github.com/etkecc/synapse-admin.git`
- change into downloaded directory: `cd synapse-admin` - change into downloaded directory: `cd synapse-admin`
- download dependencies: `yarn install` - download dependencies: `yarn install`
- start web server: `yarn start` - start web server: `yarn start`
#### 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 ghcr.io/etkecc/synapse-admin` or use the [docker-compose.yml](docker-compose.yml): `docker-compose up -d`
> note: if you're building on an architecture other than amd64 (for example a raspberry pi), make sure to define a maximum ram for node. otherwise the build will fail. > 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.
@@ -113,7 +199,7 @@ You have three options:
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/etkecc/synapse-admin.git
args: args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 - BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
# - NODE_OPTIONS="--max_old_space_size=1024" # - NODE_OPTIONS="--max_old_space_size=1024"
@@ -125,37 +211,6 @@ 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 ### Serving Synapse-Admin on a different path
The path prefix where synapse-admin is served can only be changed during the build step. The path prefix where synapse-admin is served can only be changed during the build step.
@@ -182,7 +237,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
synapse-admin: synapse-admin:
image: awesometechnologies/synapse-admin:latest image: etkecc/synapse-admin:latest
restart: unless-stopped restart: unless-stopped
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
@@ -193,10 +248,6 @@ services:
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin" - "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
``` ```
## Screenshots
![Screenshots](./screenshots.jpg)
## Development ## Development
- See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE - See https://yarnpkg.com/getting-started/editor-sdks how to setup your IDE

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

@@ -4,6 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin"; import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { AdminLayout } from "./components/AdminLayout";
import { ImportFeature } from "./components/ImportFeature"; import { ImportFeature } from "./components/ImportFeature";
import germanMessages from "./i18n/de"; import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en"; import englishMessages from "./i18n/en";
@@ -21,6 +22,7 @@ import userMediaStats from "./resources/user_media_statistics";
import users from "./resources/users"; import users from "./resources/users";
import authProvider from "./synapse/authProvider"; import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider from "./synapse/dataProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// TODO: Can we use lazy loading together with browser locale? // TODO: Can we use lazy loading together with browser locale?
const messages = { const messages = {
@@ -45,37 +47,41 @@ const i18nProvider = polyglotI18nProvider(
] ]
); );
const queryClient = new QueryClient();
const App = () => ( const App = () => (
<Admin <QueryClientProvider client={queryClient}>
disableTelemetry <Admin
requireAuth disableTelemetry
loginPage={LoginPage} requireAuth
authProvider={authProvider} layout={AdminLayout}
dataProvider={dataProvider} loginPage={LoginPage}
i18nProvider={i18nProvider} authProvider={authProvider}
darkTheme={{ palette: { mode: "dark" } }} dataProvider={dataProvider}
> i18nProvider={i18nProvider}
<CustomRoutes> >
<Route path="/import_users" element={<ImportFeature />} /> <CustomRoutes>
</CustomRoutes> <Route path="/import_users" element={<ImportFeature />} />
<Resource {...users} /> </CustomRoutes>
<Resource {...rooms} /> <Resource {...users} />
<Resource {...userMediaStats} /> <Resource {...rooms} />
<Resource {...reports} /> <Resource {...userMediaStats} />
<Resource {...roomDirectory} /> <Resource {...reports} />
<Resource {...destinations} /> <Resource {...roomDirectory} />
<Resource {...registrationToken} /> <Resource {...destinations} />
<Resource name="connections" /> <Resource {...registrationToken} />
<Resource name="devices" /> <Resource name="connections" />
<Resource name="room_members" /> <Resource name="devices" />
<Resource name="users_media" /> <Resource name="room_members" />
<Resource name="joined_rooms" /> <Resource name="users_media" />
<Resource name="pushers" /> <Resource name="joined_rooms" />
<Resource name="servernotices" /> <Resource name="pushers" />
<Resource name="forward_extremities" /> <Resource name="servernotices" />
<Resource name="room_state" /> <Resource name="forward_extremities" />
<Resource name="destination_rooms" /> <Resource name="room_state" />
</Admin> <Resource name="destination_rooms" />
</Admin>
</QueryClientProvider>
); );
export default App; export default App;

View File

@@ -2,6 +2,8 @@ import { createContext, useContext } from "react";
interface AppContextType { interface AppContextType {
restrictBaseUrl: string | string[]; restrictBaseUrl: string | string[];
asManagedUsers: string[];
supportURL: string;
} }
export const AppContext = createContext({}); export const AppContext = createContext({});

View File

@@ -0,0 +1,26 @@
import { Layout, Menu } from 'react-admin';
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
const DEFAULT_SUPPORT_LINK = "https://github.com/etkecc/synapse-admin/issues";
const supportLink = (): string => {
try {
new URL(localStorage.getItem("support_url") || ''); // Check if the URL is valid
return localStorage.getItem("support_url") || DEFAULT_SUPPORT_LINK;
} catch (e) {
return DEFAULT_SUPPORT_LINK;
}
};
const AdminMenu = () => (
<Menu>
<Menu.ResourceItems />
<Menu.Item to={supportLink()} target="_blank" primaryText="Contact support" leftIcon={<LiveHelpIcon />} />
</Menu>
);
export const AdminLayout = ({ children }) => (
<Layout menu={AdminMenu}>
{children}
</Layout>
);

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

@@ -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

@@ -1,9 +1,15 @@
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin"; import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
import { isASManaged } from "./mxid";
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => { export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
const record = useRecordContext(); const record = useRecordContext();
if (!record) return null; if (!record) return null;
let isASManagedUser = false;
if (record.user_id) {
isASManagedUser = isASManaged(record.user_id);
}
return ( return (
<DeleteWithConfirmButton <DeleteWithConfirmButton
{...props} {...props}
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
confirmContent="resources.devices.action.erase.content" confirmContent="resources.devices.action.erase.content"
mutationMode="pessimistic" mutationMode="pessimistic"
redirect={false} redirect={false}
disabled={isASManagedUser}
translateOptions={{ translateOptions={{
id: record.id, id: record.id,
name: record.display_name ? record.display_name : record.id, name: record.display_name ? record.display_name : record.id,

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": {

16
src/components/mxid.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Identifier } from "ra-core";
/**
* Check if a user is managed by an application service
* @param id The user ID to check
* @returns Whether the user is managed by an application service
*/
export const isASManaged = (id: string | Identifier): boolean => {
const managedUsersString = localStorage.getItem("as_managed_users") || '';
try {
const asManagedUsers = JSON.parse(managedUsersString).map(regex => new RegExp(regex));
return asManagedUsers.some(regex => regex.test(id));
} catch (e) {
return false;
}
};

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",
@@ -142,8 +150,9 @@ const de: SynapseTranslationMessages = {
helper: { helper: {
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.", password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.", deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten", erase: "DSGVO konformes Löschen der Benutzerdaten.",
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt", erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
}, },
action: { action: {
erase: "Lösche Benutzerdaten", erase: "Lösche Benutzerdaten",

View File

@@ -143,6 +143,7 @@ const en: SynapseTranslationMessages = {
deactivate: "You must provide a password to re-activate an account.", deactivate: "You must provide a password to re-activate an account.",
erase: "Mark the user as GDPR-erased", erase: "Mark the user as GDPR-erased",
erase_admin_error: "Deleting own user is not allowed.", erase_admin_error: "Deleting own user is not allowed.",
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
}, },
action: { action: {
erase: "Erase user data", erase: "Erase user data",

View File

@@ -138,6 +138,8 @@ const fa: SynapseTranslationMessages = {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.", password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.", deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید", erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
}, },
action: { action: {
erase: "پاک کردن اطلاعات کاربر", erase: "پاک کردن اطلاعات کاربر",

View File

@@ -141,6 +141,7 @@ const fr: SynapseTranslationMessages = {
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.", deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD", erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.", erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
}, },
action: { action: {
erase: "Effacer les données de l'utilisateur", erase: "Effacer les données de l'utilisateur",

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

@@ -139,6 +139,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
deactivate: string; deactivate: string;
erase: string; erase: string;
erase_admin_error: string; erase_admin_error: string;
modify_managed_user_error: string;
}; };
action: { action: {
erase: string; erase: string;

View File

@@ -143,6 +143,7 @@ const it: SynapseTranslationMessages = {
action: { action: {
erase: "Cancella i dati dell'utente", erase: "Cancella i dati dell'utente",
erase_admin_error: "Non è consentito eliminare il proprio utente.", erase_admin_error: "Non è consentito eliminare il proprio utente.",
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
}, },
}, },
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: "Адрес домашнего сервера",
@@ -152,6 +160,7 @@ const ru: SynapseTranslationMessages = {
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.", deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR", erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_admin_error: "Удаление собственного пользователя запрещено.", erase_admin_error: "Удаление собственного пользователя запрещено.",
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
}, },
action: { action: {
erase: "Удалить данные пользователя", erase: "Удалить данные пользователя",

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",
@@ -136,6 +144,7 @@ const zh: SynapseTranslationMessages = {
deactivate: "您必须提供一串密码来激活账户。", deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了", erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_admin_error: "不允许删除自己的用户", erase_admin_error: "不允许删除自己的用户",
modify_managed_user_error: "不允许修改系统管理的用户。",
}, },
action: { action: {
erase: "抹除用户信息", erase: "抹除用户信息",

View File

@@ -4,15 +4,18 @@ import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import { AppContext } from "./AppContext"; import { AppContext } from "./AppContext";
import storage from "./storage";
fetch("config.json") fetch("config.json")
.then(res => res.json()) .then(res => res.json())
.then(props => .then(props => {
createRoot(document.getElementById("root")).render( storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
storage.setItem("support_url", props.supportURL);
return createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<AppContext.Provider value={props}> <AppContext.Provider value={props}>
<App /> <App />
</AppContext.Provider> </AppContext.Provider>
</React.StrictMode> </React.StrictMode>
) )
); });

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

@@ -61,6 +61,7 @@ import {
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField"; import AvatarField from "../components/AvatarField";
import { isASManaged } from "../components/mxid";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices"; import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date"; import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices"; import { DeviceRemoveButton } from "../components/devices";
@@ -103,8 +104,9 @@ 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; asManagedUserIsSelected: boolean }> = props => {
const ownUserIsSelected = props.ownUserIsSelected; const ownUserIsSelected = props.ownUserIsSelected;
const asManagedUserIsSelected = props.asManagedUserIsSelected;
const notify = useNotify(); const notify = useNotify();
const translate = useTranslate(); const translate = useTranslate();
@@ -112,6 +114,9 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
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();
} else if (asManagedUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
ev.stopPropagation();
} }
}; };
@@ -121,6 +126,7 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
const UserBulkActionButtons = () => { const UserBulkActionButtons = () => {
const record = useListContext(); const record = useListContext();
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false); const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = 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,12 +134,13 @@ const UserBulkActionButtons = () => {
useEffect(() => { useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId)); setOwnUserIsSelected(selectedIds.includes(ownUserId));
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
}, [selectedIds]); }, [selectedIds]);
return ( return (
<> <>
<ServerNoticeBulkButton /> <ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BulkDeleteButton <BulkDeleteButton
label="resources.users.action.erase" label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase" confirmTitle="resources.users.helper.erase"
@@ -184,14 +191,16 @@ const UserEditActions = () => {
const translate = useTranslate(); const translate = useTranslate();
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false; let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) { if (record && record.id) {
ownUserIsSelected = record.id === ownUserId; ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
} }
return ( return (
<TopToolbar> <TopToolbar>
{!record?.deactivated && <ServerNoticeButton />} {!record?.deactivated && <ServerNoticeButton />}
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton <DeleteButton
label="resources.users.action.erase" label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", { confirmTitle={translate("resources.users.helper.erase", {
@@ -235,13 +244,21 @@ export const UserCreate = (props: CreateProps) => (
const UserTitle = () => { const UserTitle = () => {
const record = useRecordContext(); const record = useRecordContext();
if (!record) {
return null;
}
const translate = useTranslate(); const translate = useTranslate();
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
if (isASManaged(record?.id)) {
username += " 🤖";
}
return ( return (
<span> <span>
{translate("resources.users.name", { {translate("resources.users.name", {
smart_count: 1, smart_count: 1,
})}{" "} })}{" "}
{record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""} {username}
</span> </span>
); );
}; };
@@ -250,8 +267,10 @@ const UserEditToolbar = () => {
const record = useRecordContext(); const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false; let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) { if (record && record.id) {
ownUserIsSelected = record.id === ownUserId; ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
} }
return ( return (
@@ -259,7 +278,7 @@ const UserEditToolbar = () => {
<div className={ToolbarClasses.defaultToolbar}> <div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}> <Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton /> <SaveButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton /> <DeleteButton />
</UserPreventSelfDelete> </UserPreventSelfDelete>
</Toolbar> </Toolbar>
@@ -272,35 +291,53 @@ const UserBooleanInput = props => {
const record = useRecordContext(); const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false; let ownUserIsSelected = false;
if (record && record.id === ownUserId) { let asManagedUserIsSelected = false;
ownUserIsSelected = true; if (record) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
} }
return ( return (
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected} /> <BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
</UserPreventSelfDelete> </UserPreventSelfDelete>
); );
}; };
const UserPasswordInput = props => {
const record = useRecordContext();
let asManagedUserIsSelected = false;
if (record) {
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error" disabled={asManagedUserIsSelected} />
);
};
export const UserEdit = (props: EditProps) => { export const UserEdit = (props: EditProps) => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}> <Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<TabbedForm 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" }} /> <AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px" }} />
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" /> <BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
<ImageInput source="avatar_file" label="resources.users.fields.avatar" accept="image/*"> <ImageInput
<ImageField source="src" title="Avatar" /> source="avatar_file"
label="resources.users.fields.avatar"
accept={{ "image/*": [".png", ".jpg"] }}
>
<ImageField source="src" title="Avatar" />
</ImageInput> </ImageInput>
<TextInput source="id" disabled /> <TextInput source="id" readOnly />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" /> <UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable /> <SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" /> <BooleanInput source="admin" />
<BooleanInput source="locked" /> <UserBooleanInput source="locked" />
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" /> <UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
<BooleanInput source="erased" disabled /> <BooleanInput source="erased" disabled />
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} /> <DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
@@ -327,7 +364,7 @@ export const UserEdit = (props: EditProps) => {
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices"> <FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
<ReferenceManyField reference="devices" target="user_id" label={false}> <ReferenceManyField reference="devices" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }}> <Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<TextField source="device_id" sortable={false} /> <TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} /> <TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} /> <TextField source="last_seen_ip" sortable={false} />

View File

@@ -1,12 +1,12 @@
import { stringify } from "query-string";
import { import {
DataProvider, DataProvider,
DeleteParams, DeleteParams,
HttpError, HttpError,
Identifier, Identifier,
Options, Options,
PaginationPayload,
RaRecord, RaRecord,
SortPayload,
UpdateParams, UpdateParams,
fetchUtils, fetchUtils,
withLifecycleCallbacks, withLifecycleCallbacks,
@@ -52,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;
@@ -527,8 +531,8 @@ 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,
@@ -550,7 +554,7 @@ const baseDataProvider: 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 {
@@ -604,7 +608,7 @@ const baseDataProvider: 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 {

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