Merge branch 'master' into user_erased_details

This commit is contained in:
dklimpel 2024-02-07 19:57:25 +01:00
commit 80135753a4
28 changed files with 3676 additions and 3872 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

@ -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";
@ -32,7 +35,7 @@ const ReportPagination = () => (
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", {
@ -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

@ -1,78 +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",
// 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" });
},
}
);
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

@ -98,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" />

View File

@ -189,7 +189,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",
@ -211,6 +211,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

@ -208,6 +208,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

@ -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 }) => ({

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