Merge branch 'master' into patch-2

This commit is contained in:
dklimpel 2024-02-07 20:00:17 +01:00
commit 176698cba6
29 changed files with 3806 additions and 4009 deletions

View File

@ -20,7 +20,7 @@ jobs:
yarn build yarn build
- name: Deploy 🚀 - name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4.4.3 uses: JamesIves/github-pages-deploy-action@v4.5.0
with: with:
branch: gh-pages branch: gh-pages
folder: build folder: build

View File

@ -13,10 +13,10 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
### Supported Synapse ### Supported Synapse
It needs at least [Synapse](https://github.com/matrix-org/synapse) v1.52.0 for all functions to work as expected! It needs at least [Synapse](https://github.com/element-hq/synapse) v1.52.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`. You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://matrix-org.github.io/synapse/develop/admin_api/version_api.html). See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
After entering the URL on the login page of synapse-admin the server version appears below the input field. After entering the URL on the login page of synapse-admin the server version appears below the input field.
@ -27,7 +27,7 @@ You need access to the following endpoints:
- `/_matrix` - `/_matrix`
- `/_synapse/admin` - `/_synapse/admin`
See also [Synapse administration endpoints](https://matrix-org.github.io/synapse/develop/reverse_proxy.html#synapse-administration-endpoints) See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
### Use without install ### Use without install

View File

@ -10,29 +10,28 @@
"url": "https://github.com/Awesome-Technologies/synapse-admin" "url": "https://github.com/Awesome-Technologies/synapse-admin"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.5.2",
"eslint": "^8.55.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^5.1.3",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"prettier": "^2.2.0" "prettier": "^3.2.5"
}, },
"dependencies": { "dependencies": {
"@mui/icons-material": "^5.14.19", "@mui/icons-material": "^5.15.7",
"@mui/material": "^5.14.8", "@mui/material": "^5.15.7",
"@mui/styles": "5.14.10", "@mui/styles": "^5.15.8",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"prop-types": "^15.8.1",
"ra-language-chinese": "^2.0.10", "ra-language-chinese": "^2.0.10",
"ra-language-french": "^4.16.2", "ra-language-french": "^4.16.9",
"ra-language-german": "^3.13.4", "ra-language-german": "^3.13.4",
"ra-language-italian": "^3.13.1", "ra-language-italian": "^3.13.1",
"react": "^17.0.0", "react": "^18.0.0",
"react-admin": "^4.16.9", "react-admin": "^4.16.9",
"react-dom": "^17.0.2", "react-dom": "^18.0.0",
"react-scripts": "^5.0.1" "react-scripts": "^5.0.1"
}, },
"scripts": { "scripts": {

View File

@ -40,6 +40,7 @@ const i18nProvider = polyglotI18nProvider(
const App = () => ( const App = () => (
<Admin <Admin
disableTelemetry disableTelemetry
requireAuth
loginPage={LoginPage} loginPage={LoginPage}
authProvider={authProvider} authProvider={authProvider}
dataProvider={dataProvider} dataProvider={dataProvider}

View File

@ -6,7 +6,17 @@ import { useRecordContext } from "react-admin";
const AvatarField = ({ source, ...rest }) => { const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest); const record = useRecordContext(rest);
const src = get(record, source)?.toString(); const src = get(record, source)?.toString();
return <Avatar src={src} {...rest} />; const { alt, classes, sizes, sx, variant } = rest;
return (
<Avatar
alt={alt}
classes={classes}
sizes={sizes}
src={src}
sx={sx}
variant={variant}
/>
);
}; };
export default AvatarField; export default AvatarField;

View File

@ -0,0 +1,18 @@
import React from "react";
import { RecordContextProvider } from "react-admin";
import { render, screen } from "@testing-library/react";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
it("shows image", () => {
const value = {
avatar: "foo",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
});
});

View File

@ -2,6 +2,7 @@ import React from "react";
import { import {
Datagrid, Datagrid,
DateField, DateField,
DeleteButton,
List, List,
NumberField, NumberField,
Pagination, Pagination,
@ -10,6 +11,8 @@ import {
Tab, Tab,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
TopToolbar,
useRecordContext,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import PageviewIcon from "@mui/icons-material/Pageview"; import PageviewIcon from "@mui/icons-material/Pageview";
@ -25,14 +28,14 @@ const date_format = {
second: "2-digit", second: "2-digit",
}; };
const ReportPagination = props => ( const ReportPagination = () => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
export const ReportShow = props => { export const ReportShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
<Show {...props}> <Show {...props} actions={<ReportShowActions />}>
<TabbedShowLayout> <TabbedShowLayout>
<Tab <Tab
label={translate("synapseadmin.reports.tabs.basic", { label={translate("synapseadmin.reports.tabs.basic", {
@ -91,7 +94,7 @@ export const ReportShow = props => {
<TextField source="event_json.content.algorithm" /> <TextField source="event_json.content.algorithm" />
<TextField <TextField
source="event_json.content.device_id" source="event_json.content.device_id"
label="resources.users.fields.device_id" label="resources.devices.fields.device_id"
/> />
</Tab> </Tab>
</TabbedShowLayout> </TabbedShowLayout>
@ -99,6 +102,21 @@ export const ReportShow = props => {
); );
}; };
const ReportShowActions = () => {
const record = useRecordContext();
return (
<TopToolbar>
<DeleteButton
record={record}
mutationMode="pessimistic"
confirmTitle="resources.reports.action.erase.title"
confirmContent="resources.reports.action.erase.content"
/>
</TopToolbar>
);
};
export const ReportList = props => ( export const ReportList = props => (
<List <List
{...props} {...props}

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
fetchUtils,
Form, Form,
FormDataConsumer, FormDataConsumer,
Notification, Notification,
@ -27,6 +26,13 @@ import {
} from "@mui/material"; } from "@mui/material";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import {
getServerVersion,
getSupportedLoginFlows,
getWellKnownUrl,
isValidBaseUrl,
splitMxid,
} from "../synapse/synapse";
const FormBox = styled(Box)(({ theme }) => ({ const FormBox = styled(Box)(({ theme }) => ({
display: "flex", display: "flex",
@ -170,87 +176,42 @@ const LoginPage = () => {
window.location.href = ssoFullUrl; window.location.href = ssoFullUrl;
}; };
const extractHomeServer = username => {
const usernameRegex = /@[a-zA-Z0-9._=\-/]+:([a-zA-Z0-9\-.]+\.[a-zA-Z]+)/;
if (!username) return null;
const res = username.match(usernameRegex);
if (res) return res[1];
return null;
};
const UserData = ({ formData }) => { const UserData = ({ formData }) => {
const form = useFormContext(); const form = useFormContext();
const [serverVersion, setServerVersion] = useState(""); const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => { const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return; if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordially // check if username is a full qualified userId then set base_url accordingly
const home_server = extractHomeServer(formData.username); const domain = splitMxid(formData.username)?.domain;
const wellKnownUrl = `https://${home_server}/.well-known/matrix/client`; if (domain) {
if (home_server) { getWellKnownUrl(domain).then(url => form.setValue("base_url", url));
// fetch .well-known entry to get base_url
fetchUtils
.fetchJson(wellKnownUrl, { method: "GET" })
.then(({ json }) => {
form.setValue("base_url", json["m.homeserver"].base_url);
})
.catch(_ => {
// if there is no .well-known entry, try the home server name
form.setValue("base_url", `https://${home_server}`);
});
} }
}; };
useEffect( useEffect(() => {
_ => { if (!isValidBaseUrl(formData.base_url)) return;
if (
!formData.base_url || getServerVersion(formData.base_url)
!formData.base_url.match( .then(serverVersion =>
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/
)
)
return;
const versionUrl = `${formData.base_url}/_synapse/admin/v1/server_version`;
fetchUtils
.fetchJson(versionUrl, { method: "GET" })
.then(({ json }) => {
setServerVersion( setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${ `${translate("synapseadmin.auth.server_version")} ${serverVersion}`
json["server_version"] )
}` )
); .catch(() => setServerVersion(""));
})
.catch(_ => {
setServerVersion("");
});
// Set SSO Url // Set SSO Url
const authMethodUrl = `${formData.base_url}/_matrix/client/r0/login`; getSupportedLoginFlows(formData.base_url)
let supportPass = false, .then(loginFlows => {
supportSSO = false; const supportPass =
fetchUtils loginFlows.find(f => f.type === "m.login.password") !== undefined;
.fetchJson(authMethodUrl, { method: "GET" }) const supportSSO =
.then(({ json }) => { loginFlows.find(f => f.type === "m.login.sso") !== undefined;
json.flows.forEach(f => {
if (f.type === "m.login.password") {
supportPass = true;
} else if (f.type === "m.login.sso") {
supportSSO = true;
}
});
setSupportPassAuth(supportPass); setSupportPassAuth(supportPass);
if (supportSSO) { setSSOBaseUrl(supportSSO ? formData.base_url : "");
setSSOBaseUrl(formData.base_url);
} else {
setSSOBaseUrl("");
}
}) })
.catch(_ => { .catch(() => setSSOBaseUrl(""));
setSSOBaseUrl(""); }, [formData.base_url]);
});
},
[formData.base_url]
);
return ( return (
<> <>

View File

@ -12,6 +12,7 @@ import {
NumberField, NumberField,
NumberInput, NumberInput,
regex, regex,
SaveButton,
SimpleForm, SimpleForm,
TextInput, TextInput,
TextField, TextField,
@ -79,8 +80,15 @@ export const RegistrationTokenList = props => (
); );
export const RegistrationTokenCreate = props => ( export const RegistrationTokenCreate = props => (
<Create {...props}> <Create {...props} redirect="list">
<SimpleForm redirect="list" toolbar={<Toolbar alwaysEnableSaveButton />}> <SimpleForm
toolbar={
<Toolbar>
{/* It is possible to create tokens per default without input. */}
<SaveButton alwaysEnable />
</Toolbar>
}
>
<TextInput <TextInput
source="token" source="token"
autoComplete="off" autoComplete="off"

View File

@ -13,6 +13,7 @@ import {
TextField, TextField,
TopToolbar, TopToolbar,
useCreate, useCreate,
useDataProvider,
useListContext, useListContext,
useNotify, useNotify,
useTranslate, useTranslate,
@ -24,11 +25,11 @@ import { useMutation } from "react-query";
import RoomDirectoryIcon from "@mui/icons-material/FolderShared"; import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
const RoomDirectoryPagination = props => ( const RoomDirectoryPagination = () => (
<Pagination {...props} rowsPerPageOptions={[100, 500, 1000, 2000]} /> <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />
); );
export const RoomDirectoryDeleteButton = props => { export const RoomDirectoryUnpublishButton = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
@ -49,7 +50,7 @@ export const RoomDirectoryDeleteButton = props => {
); );
}; };
export const RoomDirectoryBulkDeleteButton = props => ( export const RoomDirectoryBulkUnpublishButton = props => (
<BulkDeleteButton <BulkDeleteButton
{...props} {...props}
label="resources.room_directory.action.erase" label="resources.room_directory.action.erase"
@ -61,57 +62,59 @@ export const RoomDirectoryBulkDeleteButton = props => (
/> />
); );
export const RoomDirectoryBulkSaveButton = () => { export const RoomDirectoryBulkPublishButton = props => {
const { selectedIds } = useListContext(); const { selectedIds } = useListContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
const unselectAll = useUnselectAll(); const unselectAllRooms = useUnselectAll("rooms");
const { createMany, isloading } = useMutation(); const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
const handleSend = values => { () =>
createMany( dataProvider.createMany("room_directory", {
["room_directory", "createMany", { ids: selectedIds, data: {} }], ids: selectedIds,
data: {},
}),
{ {
onSuccess: data => { onSuccess: () => {
notify("resources.room_directory.action.send_success"); notify("resources.room_directory.action.send_success");
unselectAll("rooms"); unselectAllRooms();
refresh(); refresh();
}, },
onError: error => onError: () =>
notify("resources.room_directory.action.send_failure", { notify("resources.room_directory.action.send_failure", {
type: "error", type: "error",
}), }),
} }
); );
};
return ( return (
<Button <Button
{...props}
label="resources.room_directory.action.create" label="resources.room_directory.action.create"
onClick={handleSend} onClick={mutate}
disabled={isloading} disabled={isLoading}
> >
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
}; };
export const RoomDirectorySaveButton = () => { export const RoomDirectoryPublishButton = props => {
const record = useRecordContext(); const record = useRecordContext();
const notify = useNotify(); const notify = useNotify();
const refresh = useRefresh(); const refresh = useRefresh();
const [create, { isloading }] = useCreate(); const [create, { isLoading }] = useCreate();
const handleSend = () => { const handleSend = () => {
create( create(
"room_directory", "room_directory",
{ data: { id: record.id } }, { data: { id: record.id } },
{ {
onSuccess: _data => { onSuccess: () => {
notify("resources.room_directory.action.send_success"); notify("resources.room_directory.action.send_success");
refresh(); refresh();
}, },
onError: _error => onError: () =>
notify("resources.room_directory.action.send_failure", { notify("resources.room_directory.action.send_failure", {
type: "error", type: "error",
}), }),
@ -121,17 +124,16 @@ export const RoomDirectorySaveButton = () => {
return ( return (
<Button <Button
{...props}
label="resources.room_directory.action.create" label="resources.room_directory.action.create"
onClick={handleSend} onClick={handleSend}
disabled={isloading} disabled={isLoading}
> >
<RoomDirectoryIcon /> <RoomDirectoryIcon />
</Button> </Button>
); );
}; };
const RoomDirectoryBulkActionButtons = () => <RoomDirectoryBulkDeleteButton />;
const RoomDirectoryListActions = () => ( const RoomDirectoryListActions = () => (
<TopToolbar> <TopToolbar>
<SelectColumnsButton /> <SelectColumnsButton />
@ -146,8 +148,8 @@ export const RoomDirectoryList = () => (
actions={<RoomDirectoryListActions />} actions={<RoomDirectoryListActions />}
> >
<DatagridConfigurable <DatagridConfigurable
rowClick={(id, resource, record) => "/rooms/" + id + "/show"} rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkActionButtons />} bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]} omit={["room_id", "canonical_alias", "topic"]}
> >
<AvatarField <AvatarField

View File

@ -7,6 +7,7 @@ import {
Toolbar, Toolbar,
required, required,
useCreate, useCreate,
useDataProvider,
useListContext, useListContext,
useNotify, useNotify,
useRecordContext, useRecordContext,
@ -23,7 +24,7 @@ import {
DialogTitle, DialogTitle,
} from "@mui/material"; } from "@mui/material";
const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => { const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const ServerNoticeToolbar = props => ( const ServerNoticeToolbar = props => (
@ -47,11 +48,7 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => {
<DialogContentText> <DialogContentText>
{translate("resources.servernotices.helper.send")} {translate("resources.servernotices.helper.send")}
</DialogContentText> </DialogContentText>
<SimpleForm <SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
toolbar={<ServerNoticeToolbar />}
redirect={false}
onSubmit={onSend}
>
<TextInput <TextInput
source="body" source="body"
label="resources.servernotices.fields.body" label="resources.servernotices.fields.body"
@ -105,7 +102,7 @@ export const ServerNoticeButton = () => {
<ServerNoticeDialog <ServerNoticeDialog
open={open} open={open}
onClose={handleDialogClose} onClose={handleDialogClose}
onSend={handleSend} onSubmit={handleSend}
/> />
</> </>
); );
@ -114,43 +111,44 @@ export const ServerNoticeButton = () => {
export const ServerNoticeBulkButton = () => { export const ServerNoticeBulkButton = () => {
const { selectedIds } = useListContext(); const { selectedIds } = useListContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const notify = useNotify(); const notify = useNotify();
const unselectAll = useUnselectAll(); const unselectAllUsers = useUnselectAll("users");
const { createMany, isloading } = useMutation(); const dataProvider = useDataProvider();
const handleDialogOpen = () => setOpen(true); const { mutate: sendNotices, isLoading } = useMutation(
const handleDialogClose = () => setOpen(false); data =>
dataProvider.createMany("servernotices", {
const handleSend = values => { ids: selectedIds,
createMany( data: data,
["servernotices", "createMany", { ids: selectedIds, data: values }], }),
{ {
onSuccess: data => { onSuccess: () => {
notify("resources.servernotices.action.send_success"); notify("resources.servernotices.action.send_success");
unselectAll("users"); unselectAllUsers();
handleDialogClose(); closeDialog();
}, },
onError: error => onError: () =>
notify("resources.servernotices.action.send_failure", { notify("resources.servernotices.action.send_failure", {
type: "error", type: "error",
}), }),
} }
); );
};
return ( return (
<> <>
<Button <Button
label="resources.servernotices.send" label="resources.servernotices.send"
onClick={handleDialogOpen} onClick={openDialog}
disabled={isloading} disabled={isLoading}
> >
<MessageIcon /> <MessageIcon />
</Button> </Button>
<ServerNoticeDialog <ServerNoticeDialog
open={open} open={open}
onClose={handleDialogClose} onClose={closeDialog}
onSend={handleSend} onSubmit={sendNotices}
/> />
</> </>
); );

View File

@ -24,8 +24,8 @@ import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared"; import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList"; import ViewListIcon from "@mui/icons-material/ViewList";
const DestinationPagination = props => ( const DestinationPagination = () => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
const date_format = { const date_format = {

View File

@ -1,77 +0,0 @@
import React, { useState } from "react";
import {
Button,
useDelete,
useNotify,
Confirm,
useRecordContext,
useRefresh,
} from "react-admin";
import ActionDelete from "@mui/icons-material/Delete";
import { alpha, useTheme } from "@mui/material/styles";
export const DeviceRemoveButton = props => {
const theme = useTheme();
const record = useRecordContext();
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice, { isLoading }] = useDelete();
if (!record) return null;
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleConfirm = () => {
removeDevice(
"devices",
{ id: record.id, meta: { user_id: record.user_id } },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onFailure: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
setOpen(false);
};
return (
<>
<Button
{...props}
label="ra.action.remove"
onClick={handleClick}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
}}
>
<ActionDelete />
</Button>
<Confirm
isOpen={open}
loading={isLoading}
onConfirm={handleConfirm}
onClose={handleDialogClose}
title="resources.devices.action.erase.title"
content="resources.devices.action.erase.content"
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
</>
);
};

View File

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

View File

@ -29,7 +29,7 @@ import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockOpenIcon from "@mui/icons-material/LockOpen";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => { const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
const dateParser = v => { const dateParser = v => {
@ -38,8 +38,7 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
return d.getTime(); return d.getTime();
}; };
const DeleteMediaToolbar = props => { const DeleteMediaToolbar = props => (
return (
<Toolbar {...props}> <Toolbar {...props}>
<SaveButton <SaveButton
label="resources.delete_media.action.send" label="resources.delete_media.action.send"
@ -50,7 +49,6 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
</Button> </Button>
</Toolbar> </Toolbar>
); );
};
return ( return (
<Dialog open={open} onClose={onClose} loading={loading}> <Dialog open={open} onClose={onClose} loading={loading}>
@ -61,11 +59,7 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
<DialogContentText> <DialogContentText>
{translate("resources.delete_media.helper.send")} {translate("resources.delete_media.helper.send")}
</DialogContentText> </DialogContentText>
<SimpleForm <SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
toolbar={<DeleteMediaToolbar />}
redirect={false}
save={onSend}
>
<DateTimeInput <DateTimeInput
fullWidth fullWidth
source="before_ts" source="before_ts"
@ -99,17 +93,18 @@ export const DeleteMediaButton = props => {
const notify = useNotify(); const notify = useNotify();
const [deleteOne, { isLoading }] = useDelete(); const [deleteOne, { isLoading }] = useDelete();
const handleDialogOpen = () => setOpen(true); const openDialog = () => setOpen(true);
const handleDialogClose = () => setOpen(false); const closeDialog = () => setOpen(false);
const handleSend = values => { const deleteMedia = values => {
deleteOne( deleteOne(
"delete_media", "delete_media",
{ id: values.id }, // needs meta.before_ts, meta.size_gt and meta.keep_profiles
{ meta: values },
{ {
onSuccess: () => { onSuccess: () => {
notify("resources.delete_media.action.send_success"); notify("resources.delete_media.action.send_success");
handleDialogClose(); closeDialog();
}, },
onError: () => onError: () =>
notify("resources.delete_media.action.send_failure", { notify("resources.delete_media.action.send_failure", {
@ -124,7 +119,7 @@ export const DeleteMediaButton = props => {
<Button <Button
{...props} {...props}
label="resources.delete_media.action.send" label="resources.delete_media.action.send"
onClick={handleDialogOpen} onClick={openDialog}
disabled={isLoading} disabled={isLoading}
sx={{ sx={{
color: theme.palette.error.main, color: theme.palette.error.main,
@ -141,14 +136,14 @@ export const DeleteMediaButton = props => {
</Button> </Button>
<DeleteMediaDialog <DeleteMediaDialog
open={open} open={open}
onClose={handleDialogClose} onClose={closeDialog}
onSend={handleSend} onSubmit={deleteMedia}
/> />
</> </>
); );
}; };
export const ProtectMediaButton = props => { export const ProtectMediaButton = () => {
const record = useRecordContext(); const record = useRecordContext();
const translate = useTranslate(); const translate = useTranslate();
const refresh = useRefresh(); const refresh = useRefresh();
@ -276,7 +271,7 @@ export const QuarantineMediaButton = props => {
const handleRemoveQuarantaine = () => { const handleRemoveQuarantaine = () => {
deleteOne( deleteOne(
"quarantine_media", "quarantine_media",
{ id: record.id }, { id: record.id, previousData: record },
{ {
onSuccess: () => { onSuccess: () => {
notify("resources.quarantine_media.action.send_success"); notify("resources.quarantine_media.action.send_success");

View File

@ -36,10 +36,10 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EventIcon from "@mui/icons-material/Event"; import EventIcon from "@mui/icons-material/Event";
import RoomIcon from "@mui/icons-material/ViewList"; import RoomIcon from "@mui/icons-material/ViewList";
import { import {
RoomDirectoryBulkDeleteButton, RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkSaveButton, RoomDirectoryBulkPublishButton,
RoomDirectoryDeleteButton, RoomDirectoryUnpublishButton,
RoomDirectorySaveButton, RoomDirectoryPublishButton,
} from "./RoomDirectory"; } from "./RoomDirectory";
const date_format = { const date_format = {
@ -51,8 +51,8 @@ const date_format = {
second: "2-digit", second: "2-digit",
}; };
const RoomPagination = props => ( const RoomPagination = () => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
const RoomTitle = () => { const RoomTitle = () => {
@ -79,14 +79,9 @@ const RoomShowActions = () => {
return ( return (
<TopToolbar> <TopToolbar>
{roomDirectoryStatus === false && ( {roomDirectoryStatus === false && <RoomDirectoryPublishButton />}
<RoomDirectorySaveButton record={record} /> {roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
)}
{roomDirectoryStatus === true && (
<RoomDirectoryDeleteButton record={record} />
)}
<DeleteButton <DeleteButton
record={record}
mutationMode="pessimistic" mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title" confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content" confirmContent="resources.rooms.action.erase.content"
@ -103,6 +98,7 @@ export const RoomShow = props => {
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}> <Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" /> <TextField source="room_id" />
<TextField source="name" /> <TextField source="name" />
<TextField source="topic" />
<TextField source="canonical_alias" /> <TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users"> <ReferenceField source="creator" reference="users">
<TextField source="id" /> <TextField source="id" />
@ -280,8 +276,8 @@ export const RoomShow = props => {
const RoomBulkActionButtons = () => ( const RoomBulkActionButtons = () => (
<> <>
<RoomDirectoryBulkSaveButton /> <RoomDirectoryBulkPublishButton />
<RoomDirectoryBulkDeleteButton /> <RoomDirectoryBulkUnpublishButton />
<BulkDeleteButton <BulkDeleteButton
confirmTitle="resources.rooms.action.erase.title" confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content" confirmContent="resources.rooms.action.erase.content"

View File

@ -17,14 +17,8 @@ import { DeleteMediaButton } from "./media";
const ListActions = props => { const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props; const { className, exporter, filters, maxResults, ...rest } = props;
const { const { sort, resource, displayedFilters, filterValues, showFilter, total } =
currentSort, useListContext();
resource,
displayedFilters,
filterValues,
showFilter,
total,
} = useListContext();
return ( return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}> <TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters && {filters &&
@ -39,7 +33,7 @@ const ListActions = props => {
<ExportButton <ExportButton
disabled={total === 0} disabled={total === 0}
resource={resource} resource={resource}
sort={currentSort} sort={sort}
filterValues={filterValues} filterValues={filterValues}
maxResults={maxResults} maxResults={maxResults}
/> />
@ -47,8 +41,8 @@ const ListActions = props => {
); );
}; };
const UserMediaStatsPagination = props => ( const UserMediaStatsPagination = () => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />]; const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];

View File

@ -73,7 +73,7 @@ const date_format = {
}; };
const UserListActions = ({ const UserListActions = ({
currentSort, sort,
className, className,
resource, resource,
filters, filters,
@ -103,7 +103,7 @@ const UserListActions = ({
<ExportButton <ExportButton
disabled={total === 0} disabled={total === 0}
resource={resource} resource={resource}
sort={currentSort} sort={sort}
filter={{ ...filterValues, ...permanentFilter }} filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter} exporter={exporter}
maxResults={maxResults} maxResults={maxResults}
@ -121,8 +121,8 @@ UserListActions.defaultProps = {
onUnselectItems: () => null, onUnselectItems: () => null,
}; };
const UserPagination = props => ( const UserPagination = () => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
const userFilters = [ const userFilters = [

View File

@ -188,7 +188,7 @@ const de = {
}, },
}, },
reports: { reports: {
name: "Ereignisbericht |||| Ereignisberichte", name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse",
fields: { fields: {
id: "ID", id: "ID",
received_ts: "Meldezeit", received_ts: "Meldezeit",
@ -210,6 +210,13 @@ const de = {
}, },
}, },
}, },
action: {
erase: {
title: "Gemeldetes Event löschen",
content:
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
},
},
}, },
connections: { connections: {
name: "Verbindungen", name: "Verbindungen",

View File

@ -207,6 +207,13 @@ const en = {
}, },
}, },
}, },
action: {
erase: {
title: "Delete reported event",
content:
"Are you sure you want to delete the reported event? This cannot be undone.",
},
},
}, },
connections: { connections: {
name: "Connections", name: "Connections",

View File

@ -1,5 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

9
src/index.jsx Normal file
View File

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

View File

@ -98,7 +98,7 @@ const resourceMap = {
}), }),
delete: params => ({ delete: params => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent( endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
params.meta.user_id params.previousData.user_id
)}/devices/${params.id}`, )}/devices/${params.id}`,
}), }),
}, },
@ -456,7 +456,7 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.data.id)}`, { return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
@ -546,7 +546,7 @@ const dataProvider = {
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, { return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.previousData, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
data: json, data: json,
})); }));

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

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

View File

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

7118
yarn.lock

File diff suppressed because it is too large Load Diff