Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Albert a2807fe281 Allow importing csv files
Change-Id: I8f4c417c1762ba6dbc0fa929ecf8262e6e8a2e42
2020-04-15 11:38:11 +02:00
26 changed files with 1844 additions and 3587 deletions
+1
View File
@@ -1,4 +1,5 @@
# Exclude a bunch of stuff which can make the build context a larger than it needs to be # Exclude a bunch of stuff which can make the build context a larger than it needs to be
.git/
tests/ tests/
build/ build/
lib/ lib/
+1 -1
View File
@@ -1,5 +1,5 @@
# Builder # Builder
FROM node:current as builder FROM node:10-alpine as builder
WORKDIR /src WORKDIR /src
+2 -29
View File
@@ -4,33 +4,6 @@
This project is built using [react-admin](https://marmelab.com/react-admin/). This project is built using [react-admin](https://marmelab.com/react-admin/).
It needs at least Synapse v1.18.0 for all functions to work as expected! Use `yarn install` after cloning this repo.
## Step-By-Step install: Use `yarn start` to launch the webserver.
You have two options:
1. Download the source code from github and run using nodejs
2. Run the Docker container
Steps for 1):
- make sure you have installed the following: git, yarn, nodejs
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
- change into downloaded directory: `cd synapse-admin`
- download dependencies: `yarn install`
- start web server: `yarn start`
Steps for 2):
- run the Docker container: `docker run -p 8080:80 awesometechnologies/synapse-admin`
- browse to http://localhost:8080
## Screenshots
![Screenshots](./screenshots.jpg)
## Development
- Use `yarn test` to run all style, lint and unit tests
- Use `yarn fix` to fix the coding style
+7 -12
View File
@@ -1,10 +1,9 @@
{ {
"name": "synapse-admin", "name": "synapse-admin",
"version": "AMP/2020.08", "version": "0.1.0",
"description": "Admin GUI for the Matrix.org server Synapse", "description": "Admin GUI for the Matrix.org server Synapse",
"author": "Awesome Technologies Innovationslabor GmbH", "author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0", "license": "Apache-2.0",
"homepage": ".",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Awesome-Technologies/synapse-admin" "url": "https://github.com/Awesome-Technologies/synapse-admin"
@@ -12,30 +11,26 @@
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.1.1", "@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^10.0.2", "@testing-library/react": "^10.0.2",
"@testing-library/user-event": "^12.0.11", "@testing-library/user-event": "^10.0.1",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1", "eslint-config-prettier": "^6.10.1",
"eslint-plugin-prettier": "^3.1.2", "eslint-plugin-prettier": "^3.1.2",
"jest-fetch-mock": "^3.0.3",
"prettier": "^2.0.0" "prettier": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"@progress/kendo-drawing": "^1.6.0",
"@progress/kendo-react-pdf": "^3.10.1",
"babel-preset-jest": "^24.9.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode.react": "^1.0.0",
"ra-language-german": "^2.1.2", "ra-language-german": "^2.1.2",
"react": "^16.13.1", "react": "^16.13.1",
"react-admin": "^3.7.0", "react-admin": "^3.4.0",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-scripts": "^3.4.1" "react-scripts": "^3.4.1",
"react-admin-import-csv": "^0.2.5"
}, },
"scripts": { "scripts": {
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start", "start": "react-scripts start",
"build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build", "build": "react-scripts build",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
"fix:code": "yarn test:lint --fix", "fix:code": "yarn test:lint --fix",
"fix": "yarn fix:code && yarn fix:other", "fix": "yarn fix:code && yarn fix:other",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

-33
View File
@@ -9,32 +9,6 @@
name="description" name="description"
content="Synapse-Admin" content="Synapse-Admin"
/> />
<style>
@font-face {
font-family: "DejaVu Sans";
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-weight: bold;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Bold.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-style: italic;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Oblique.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans";
font-weight: bold;
font-style: italic;
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Oblique.ttf") format("truetype");
}
@font-face {
font-family: "DejaVu Sans Mono";
src: url("%PUBLIC_URL%/fonts/DejaVu/DejaVuSans-Mono.ttf") format("truetype");
}
</style>
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
@@ -64,12 +38,5 @@
To begin the development, run `npm start` or `yarn start`. To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`. To create a production bundle, use `npm run build` or `yarn build`.
--> -->
<footer
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
Synapse-Admin <b>(%REACT_APP_VERSION%)</b> by Awesome Technologies Innovationslabor GmbH
</a>
</footer>
</body> </body>
</html> </html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

+2 -16
View File
@@ -4,14 +4,12 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider"; import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users"; import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList, RoomCreate, RoomShow } from "./components/rooms"; import { RoomList } from "./components/rooms";
import LoginPage from "./components/LoginPage"; import LoginPage from "./components/LoginPage";
import UserIcon from "@material-ui/icons/Group"; import UserIcon from "@material-ui/icons/Group";
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList"; import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
import germanMessages from "./i18n/de"; import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en"; import englishMessages from "./i18n/en";
import ShowUserPdf from "./components/ShowUserPdf";
import { Route } from "react-router-dom";
// TODO: Can we use lazy loading together with browser locale? // TODO: Can we use lazy loading together with browser locale?
const messages = { const messages = {
@@ -29,9 +27,6 @@ const App = () => (
authProvider={authProvider} authProvider={authProvider}
dataProvider={dataProvider} dataProvider={dataProvider}
i18nProvider={i18nProvider} i18nProvider={i18nProvider}
customRoutes={[
<Route key="showpdf" path="/showpdf" component={ShowUserPdf} />,
]}
> >
<Resource <Resource
name="users" name="users"
@@ -40,17 +35,8 @@ const App = () => (
edit={UserEdit} edit={UserEdit}
icon={UserIcon} icon={UserIcon}
/> />
<Resource <Resource name="rooms" list={RoomList} icon={RoomIcon} />
name="rooms"
list={RoomList}
create={RoomCreate}
show={RoomShow}
icon={RoomIcon}
/>
<Resource name="connections" /> <Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="servernotices" />
</Admin> </Admin>
); );
+35 -123
View File
@@ -1,17 +1,13 @@
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { import {
fetchUtils,
FormDataConsumer,
Notification, Notification,
useLogin, useLogin,
useNotify, useNotify,
useLocale, useLocale,
useSetLocale, useSetLocale,
useTranslate, useTranslate,
PasswordInput,
TextInput,
} from "react-admin"; } from "react-admin";
import { Form, useForm } from "react-final-form"; import { Field, Form } from "react-final-form";
import { import {
Avatar, Avatar,
Button, Button,
@@ -29,7 +25,7 @@ const useStyles = makeStyles(theme => ({
main: { main: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minHeight: "calc(100vh - 1em)", minHeight: "100vh",
alignItems: "center", alignItems: "center",
justifyContent: "flex-start", justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)", background: "url(./images/floating-cogs.svg)",
@@ -38,9 +34,8 @@ const useStyles = makeStyles(theme => ({
backgroundSize: "cover", backgroundSize: "cover",
}, },
card: { card: {
minWidth: "30em", minWidth: 300,
marginTop: "6em", marginTop: "6em",
marginBottom: "6em",
}, },
avatar: { avatar: {
margin: "1em", margin: "1em",
@@ -65,12 +60,6 @@ const useStyles = makeStyles(theme => ({
actions: { actions: {
padding: "0 1em 1em 1em", padding: "0 1em 1em 1em",
}, },
serverVersion: {
color: "#9e9e9e",
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
})); }));
const LoginPage = ({ theme }) => { const LoginPage = ({ theme }) => {
@@ -81,7 +70,7 @@ const LoginPage = ({ theme }) => {
var locale = useLocale(); var locale = useLocale();
const setLocale = useSetLocale(); const setLocale = useSetLocale();
const translate = useTranslate(); const translate = useTranslate();
const base_url = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
const renderInput = ({ const renderInput = ({
meta: { touched, error } = {}, meta: { touched, error } = {},
@@ -99,23 +88,15 @@ const LoginPage = ({ theme }) => {
const validate = values => { const validate = values => {
const errors = {}; const errors = {};
if (!values.homeserver) {
errors.homeserver = translate("ra.validation.required");
}
if (!values.username) { if (!values.username) {
errors.username = translate("ra.validation.required"); errors.username = translate("ra.validation.required");
} }
if (!values.password) { if (!values.password) {
errors.password = translate("ra.validation.required"); errors.password = translate("ra.validation.required");
} }
if (!values.base_url) {
errors.base_url = translate("ra.validation.required");
} else {
if (!values.base_url.match(/^(http|https):\/\//)) {
errors.base_url = translate("synapseadmin.auth.protocol_error");
} else if (
!values.base_url.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/)
) {
errors.base_url = translate("synapseadmin.auth.url_error");
}
}
return errors; return errors;
}; };
@@ -134,101 +115,9 @@ const LoginPage = ({ theme }) => {
}); });
}; };
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 form = useForm();
const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url) return;
// check if username is a full qualified userId then set base_url accordially
const home_server = extractHomeServer(formData.username);
const wellKnownUrl = `https://${home_server}/.well-known/matrix/client`;
if (home_server) {
// fetch .well-known entry to get base_url
fetchUtils
.fetchJson(wellKnownUrl, { method: "GET" })
.then(({ json }) => {
form.change("base_url", json["m.homeserver"].base_url);
})
.catch(_ => {
// if there is no .well-known entry, try the home server name
form.change("base_url", `https://${home_server}`);
});
}
};
useEffect(
_ => {
if (
!formData.base_url ||
!formData.base_url.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+$/)
)
return;
const versionUrl = `${formData.base_url}/_synapse/admin/v1/server_version`;
fetchUtils
.fetchJson(versionUrl, { method: "GET" })
.then(({ json }) => {
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${
json["server_version"]
}`
);
})
.catch(_ => {
setServerVersion("");
});
},
[formData.base_url]
);
return (
<div>
<div className={classes.input}>
<TextInput
autoFocus
name="username"
component={renderInput}
label={translate("ra.auth.username")}
disabled={loading}
onBlur={handleUsernameChange}
fullWidth
/>
</div>
<div className={classes.input}>
<PasswordInput
name="password"
component={renderInput}
label={translate("ra.auth.password")}
type="password"
disabled={loading}
fullWidth
/>
</div>
<div className={classes.input}>
<TextInput
name="base_url"
component={renderInput}
label={translate("synapseadmin.auth.base_url")}
disabled={loading}
fullWidth
/>
</div>
<div className={classes.serverVersion}>{serverVersion}</div>
</div>
);
};
return ( return (
<Form <Form
initialValues={{ base_url: base_url }} initialValues={{ homeserver: homeserver }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
@@ -257,9 +146,32 @@ const LoginPage = ({ theme }) => {
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
</Select> </Select>
</div> </div>
<FormDataConsumer> <div className={classes.input}>
{formDataProps => <UserData {...formDataProps} />} <Field
</FormDataConsumer> autoFocus
name="homeserver"
component={renderInput}
label={translate("synapseadmin.auth.homeserver")}
disabled={loading}
/>
</div>
<div className={classes.input}>
<Field
name="username"
component={renderInput}
label={translate("ra.auth.username")}
disabled={loading}
/>
</div>
<div className={classes.input}>
<Field
name="password"
component={renderInput}
label={translate("ra.auth.password")}
type="password"
disabled={loading}
/>
</div>
</div> </div>
<CardActions className={classes.actions}> <CardActions className={classes.actions}>
<Button <Button
-35
View File
@@ -1,35 +0,0 @@
import React, { useCallback } from "react";
import { SaveButton, useCreate, useRedirect, useNotify } from "react-admin";
const SaveQrButton = props => {
const [create] = useCreate("users");
const redirectTo = useRedirect();
const notify = useNotify();
const { basePath } = props;
const handleSave = useCallback(
(values, redirect) => {
create(
{
payload: { data: { ...values } },
},
{
onSuccess: ({ data: newRecord }) => {
notify("ra.notification.created", "info", {
smart_count: 1,
});
redirectTo(redirect, basePath, newRecord.id, {
password: values.password,
...newRecord,
});
},
}
);
},
[create, notify, redirectTo, basePath]
);
return <SaveButton {...props} onSave={handleSave} />;
};
export default SaveQrButton;
-148
View File
@@ -1,148 +0,0 @@
import React, { Fragment, useState } from "react";
import {
Button,
SaveButton,
SimpleForm,
TextInput,
Toolbar,
required,
useCreate,
useMutation,
useNotify,
useTranslate,
useUnselectAll,
} from "react-admin";
import MessageIcon from "@material-ui/icons/Message";
import IconCancel from "@material-ui/icons/Cancel";
import Dialog from "@material-ui/core/Dialog";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const ServerNoticeToolbar = props => (
<Toolbar {...props}>
<SaveButton label="resources.servernotices.action.send" />
<Button label="ra.action.cancel" onClick={onClose}>
<IconCancel />
</Button>
</Toolbar>
);
return (
<Dialog open={open} onClose={onClose} loading={loading}>
<DialogTitle>
{translate("resources.servernotices.action.send")}
</DialogTitle>
<DialogContent>
<DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm
toolbar={<ServerNoticeToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<TextInput
source="body"
label="resources.servernotices.fields.body"
fullWidth
multiline
rows="4"
resettable
validate={required()}
/>
</SimpleForm>
</DialogContent>
</Dialog>
);
};
export const ServerNoticeButton = ({ record }) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const [create, { loading }] = useCreate("servernotices");
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
create(
{ payload: { data: { id: record.id, ...values } } },
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
handleDialogClose();
},
onFailure: () =>
notify("resources.servernotices.action.send_failure", "error"),
}
);
};
return (
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSend={handleSend}
/>
</Fragment>
);
};
export const ServerNoticeBulkButton = ({ selectedIds }) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "servernotices",
payload: { ids: selectedIds, data: values },
},
{
onSuccess: ({ data }) => {
notify("resources.servernotices.action.send_success");
unselectAll("users");
handleDialogClose();
},
onFailure: error =>
notify("resources.servernotices.action.send_failure", "error"),
}
);
};
return (
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSend={handleSend}
/>
</Fragment>
);
};
-210
View File
@@ -1,210 +0,0 @@
import React from "react";
import { Title, Button } from "react-admin";
import { makeStyles } from "@material-ui/core/styles";
import { PDFExport } from "@progress/kendo-react-pdf";
import QRCode from "qrcode.react";
function xor(a, b) {
var res = "";
for (var i = 0; i < a.length; i++) {
res += String.fromCharCode(a.charCodeAt(i) ^ b.charCodeAt(i % b.length));
}
return res;
}
function calculateQrString(serverUrl, username, password) {
const magicString = "wo9k5tep252qxsa5yde7366kugy6c01w7oeeya9hrmpf0t7ii7";
var urlString = "user=" + username + "&password=" + password;
urlString = xor(urlString, magicString); // xor with magic string
urlString = btoa(urlString); // to base64
return serverUrl + "/#" + urlString;
}
const ShowUserPdf = props => {
const useStyles = makeStyles(theme => ({
page: {
height: 800,
width: 566,
padding: "none",
backgroundColor: "white",
boxShadow: "5px 5px 5px black",
margin: "auto",
overflowX: "hidden",
overflowY: "hidden",
fontFamily: "DejaVu Sans, Sans-Serif",
fontSize: 15,
},
header: {
height: 144,
width: 534,
marginLeft: 32,
marginTop: 15,
},
name: {
width: 240,
fontSize: 35,
float: "left",
marginTop: 100,
},
logo: {
width: 90,
marginTop: 50,
marginRight: 70,
float: "right",
},
body: {
clear: "both",
},
table_cell: {
verticalAlign: "top",
},
code_note: {
marginLeft: 32,
marginTop: 86,
},
qr: {
marginTop: 15,
marginLeft: 32,
},
credentials_note: {
marginTop: 86,
marginLeft: 10,
},
credentials_text: {
marginLeft: 10,
fontSize: 12,
},
credentials: {
fontFamily: "DejaVu Sans Mono, monospace",
},
note: {
fontSize: 18,
marginTop: 100,
marginLeft: 32,
marginRight: 32,
},
}));
const classes = useStyles();
var resume;
const exportPDF = () => {
resume.save();
};
var qrCode = "";
var displayname = "";
var id = "";
var password = "";
var username = "";
var serverUrl = "";
if (
props.location.state &&
props.location.state.id &&
props.location.state.password
) {
id = props.location.state.id;
password = props.location.state.password;
username = id.substring(1, id.indexOf(":"));
serverUrl = "https://" + id.substring(id.indexOf(":") + 1);
const qrString = calculateQrString(serverUrl, username, password);
qrCode = <QRCode value={qrString} size={128} />;
displayname = props.location.state.displayname;
}
return (
<div>
<Title title="PDF" />
<Button label="synapseadmin.action.download_pdf" onClick={exportPDF} />
<PDFExport
paperSize={"A4"}
fileName="User.pdf"
title=""
subject=""
keywords=""
ref={r => (resume = r)}
>
<div className={classes.page}>
<div className={classes.header}>
<div className={classes.name}>{displayname}</div>
<img className={classes.logo} alt="Logo" src="images/logo.png" />
</div>
<div className={classes.body}>
<table>
<tbody>
<tr>
<td width="200px">
<div className={classes.code_note}>
Ihr persönlicher Anmeldecode:
</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_note}>
Ihre persönlichen Zugangsdaten:
</div>
</td>
</tr>
<tr>
<td>
<div className={classes.qr}>{qrCode}</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_text}>
<br />
<table>
<tbody>
<tr>
<td>Heimserver:</td>
<td>
<span className={classes.credentials}>
{serverUrl}
</span>
</td>
</tr>
<tr>
<td>Benutzername:</td>
<td>
<span className={classes.credentials}>
{username}
</span>
</td>
</tr>
<tr>
<td>Passwort:</td>
<td>
<span className={classes.credentials}>
{password}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
<div className={classes.note}>
Hier können Sie Ihre selbst gewählte
Schlüsselsicherungs-Passphrase notieren:
<br />
<br />
<br />
<hr />
</div>
</div>
</div>
</PDFExport>
</div>
);
};
export default ShowUserPdf;
-89
View File
@@ -1,89 +0,0 @@
import React, { Fragment, useState } from "react";
import {
Button,
useMutation,
useNotify,
Confirm,
useRefresh,
} from "react-admin";
import ActionDelete from "@material-ui/icons/Delete";
import { makeStyles } from "@material-ui/core/styles";
import { fade } from "@material-ui/core/styles/colorManipulator";
import classnames from "classnames";
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
export const DeviceRemoveButton = props => {
const { record } = props;
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice, { loading }] = useMutation();
if (!record) return null;
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleConfirm = () => {
removeDevice(
{
type: "delete",
resource: "devices",
payload: {
id: record.id,
user_id: record.user_id,
},
},
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onFailure: () =>
notify("resources.devices.action.erase.failure", "error"),
}
);
setOpen(false);
};
return (
<Fragment>
<Button
label="ra.action.remove"
onClick={handleClick}
className={classnames("ra-delete-button", classes.deleteButton)}
>
<ActionDelete />
</Button>
<Confirm
isOpen={open}
loading={loading}
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,
}}
/>
</Fragment>
);
};
+4 -350
View File
@@ -1,363 +1,17 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { Datagrid, List, TextField, Pagination } from "react-admin";
import {
AutocompleteArrayInput,
BooleanInput,
BooleanField,
Create,
Datagrid,
Filter,
FormTab,
List,
Pagination,
ReferenceArrayInput,
ReferenceField,
ReferenceManyField,
SelectField,
Show,
Tab,
TabbedForm,
TabbedShowLayout,
TextField,
TextInput,
useTranslate,
} from "react-admin";
import get from "lodash/get";
import { Tooltip, Typography, Chip } from "@material-ui/core";
import HttpsIcon from "@material-ui/icons/Https";
import NoEncryptionIcon from "@material-ui/icons/NoEncryption";
import PageviewIcon from "@material-ui/icons/Pageview";
import UserIcon from "@material-ui/icons/Group";
import ViewListIcon from "@material-ui/icons/ViewList";
import VisibilityIcon from "@material-ui/icons/Visibility";
const RoomPagination = props => ( const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
); );
const EncryptionField = ({ source, record = {}, emptyText }) => { export const RoomList = props => (
const translate = useTranslate(); <List {...props} pagination={<RoomPagination />}>
const value = get(record, source); <Datagrid>
let ariaLabel = value === false ? "ra.boolean.false" : "ra.boolean.true";
if (value === false || value === true) {
return (
<Typography component="span" variant="body2">
<Tooltip title={translate(ariaLabel, { _: ariaLabel })}>
{value === true ? (
<HttpsIcon data-testid="true" htmlColor="limegreen" />
) : (
<NoEncryptionIcon data-testid="false" color="error" />
)}
</Tooltip>
</Typography>
);
}
return (
<Typography component="span" variant="body2">
{emptyText}
</Typography>
);
};
const validateDisplayName = fieldval =>
fieldval === undefined
? "synapseadmin.rooms.room_name_required"
: fieldval.length === 0
? "synapseadmin.rooms.room_name_required"
: undefined;
function approximateAliasLength(alias, homeserver) {
/* TODO maybe handle punycode in homeserver name */
var te;
// Support for TextEncoder is quite widespread, but the polyfill is
// pretty large; We will only underestimate the size with the regular
// length attribute of String, so we never prevent the user from using
// an alias that is short enough for the server, but too long for our
// heuristic.
try {
te = new TextEncoder();
} catch (err) {
if (err instanceof ReferenceError) {
te = undefined;
}
}
const aliasLength = te === undefined ? alias.length : te.encode(alias).length;
return "#".length + aliasLength + ":".length + homeserver.length;
}
const validateAlias = fieldval => {
if (fieldval === undefined) {
return undefined;
}
const homeserver = localStorage.getItem("home_server");
if (approximateAliasLength(fieldval, homeserver) > 255) {
return "synapseadmin.rooms.alias_too_long";
}
};
const removeLeadingWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.trimStart();
const replaceAllWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.replace(/\s/, "_");
const removeLeadingSigil = fieldVal =>
fieldVal === undefined
? undefined
: fieldVal.startsWith("#")
? fieldVal.substr(1)
: fieldVal;
const validateHasAliasIfPublic = formdata => {
let errors = {};
if (formdata.public) {
if (
formdata.canonical_alias === undefined ||
formdata.canonical_alias.trim().length === 0
) {
errors.canonical_alias = "synapseadmin.rooms.alias_required_if_public";
}
}
return errors;
};
export const RoomCreate = props => (
<Create {...props}>
<TabbedForm validate={validateHasAliasIfPublic}>
<FormTab label="synapseadmin.rooms.details" icon={<ViewListIcon />}>
<TextInput
source="name"
parse={removeLeadingWhitespace}
validate={validateDisplayName}
/>
<TextInput
source="canonical_alias"
parse={fv => replaceAllWhitespace(removeLeadingSigil(fv))}
validate={validateAlias}
placeholder="#"
/>
<BooleanInput source="public" label="synapseadmin.rooms.make_public" />
<BooleanInput
source="encrypt"
initialValue={true}
label="synapseadmin.rooms.encrypt"
/>
</FormTab>
<FormTab
label="resources.rooms.fields.invite_members"
icon={<UserIcon />}
>
<ReferenceArrayInput
reference="users"
source="invitees"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteArrayInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceArrayInput>
</FormTab>
</TabbedForm>
</Create>
);
const RoomTitle = ({ record }) => {
const translate = useTranslate();
return (
<span>
{translate("resources.rooms.name", 1)} {record ? `"${record.name}"` : ""}
</span>
);
};
export const RoomShow = props => {
const translate = useTranslate();
return (
<Show {...props} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" /> <TextField source="room_id" />
<TextField source="name" /> <TextField source="name" />
<TextField source="canonical_alias" /> <TextField source="canonical_alias" />
<TextField source="creator" />
</Tab>
<Tab
label="synapseadmin.rooms.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
<TextField source="joined_members" /> <TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="state_events" />
<TextField source="version" />
<TextField
source="encryption"
emptyText={translate("resources.rooms.enums.unencrypted")}
/>
</Tab>
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />}>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => "/users/" + id}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab
label="synapseadmin.rooms.tabs.permission"
icon={<VisibilityIcon />}
path="permission"
>
<BooleanField source="federatable" />
<BooleanField source="public" />
<SelectField
source="join_rules"
choices={[
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
{
id: "private",
name: "resources.rooms.enums.join_rules.private",
},
]}
/>
<SelectField
source="guest_access"
choices={[
{
id: "can_join",
name: "resources.rooms.enums.guest_access.can_join",
},
{
id: "forbidden",
name: "resources.rooms.enums.guest_access.forbidden",
},
]}
/>
<SelectField
source="history_visibility"
choices={[
{
id: "invited",
name: "resources.rooms.enums.history_visibility.invited",
},
{
id: "joined",
name: "resources.rooms.enums.history_visibility.joined",
},
{
id: "shared",
name: "resources.rooms.enums.history_visibility.shared",
},
{
id: "world_readable",
name: "resources.rooms.enums.history_visibility.world_readable",
},
]}
/>
</Tab>
</TabbedShowLayout>
</Show>
);
};
const RoomFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<Chip
label={translate("resources.rooms.fields.joined_local_members")}
source="joined_local_members"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.state_events")}
source="state_events"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.version")}
source="version"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.federatable")}
source="federatable"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
const FilterableRoomList = ({ ...props }) => {
const filter = props.roomFilters;
const localMembersFilter =
filter && filter.joined_local_members ? true : false;
const stateEventsFilter = filter && filter.state_events ? true : false;
const versionFilter = filter && filter.version ? true : false;
const federateableFilter = filter && filter.federatable ? true : false;
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={<RoomFilter />}
>
<Datagrid rowClick="show">
<EncryptionField
source="is_encrypted"
sortBy="encryption"
label={<HttpsIcon />}
/>
<TextField source="name" />
<TextField source="joined_members" />
{localMembersFilter && <TextField source="joined_local_members" />}
{stateEventsFilter && <TextField source="state_events" />}
{versionFilter && <TextField source="version" />}
{federateableFilter && <BooleanField source="federatable" />}
<BooleanField source="public" />
</Datagrid> </Datagrid>
</List> </List>
); );
};
function mapStateToProps(state) {
return {
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
};
}
export const RoomList = connect(mapStateToProps)(FilterableRoomList);
+54 -277
View File
@@ -1,8 +1,5 @@
import React, { cloneElement, Fragment } from "react"; import React from "react";
import Avatar from "@material-ui/core/Avatar";
import PersonPinIcon from "@material-ui/icons/PersonPin"; import PersonPinIcon from "@material-ui/icons/PersonPin";
import ContactMailIcon from "@material-ui/icons/ContactMail";
import DevicesIcon from "@material-ui/icons/Devices";
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent"; import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
import { import {
ArrayInput, ArrayInput,
@@ -13,86 +10,25 @@ import {
Edit, Edit,
List, List,
Filter, Filter,
Toolbar,
SimpleForm, SimpleForm,
SimpleFormIterator, SimpleFormIterator,
TabbedForm, TabbedForm,
FormTab, FormTab,
BooleanField, BooleanField,
BooleanInput, BooleanInput,
ImageField,
PasswordInput, PasswordInput,
TextField, TextField,
TextInput, TextInput,
SearchInput,
ReferenceField, ReferenceField,
ReferenceManyField, Toolbar,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
regex,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar, TopToolbar,
sanitizeListRestProps, SelectInput,
regex,
Pagination,
} from "react-admin"; } from "react-admin";
import SaveQrButton from "./SaveQrButton"; import { ImportButton } from "react-admin-import-csv";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices"; import { CreateButton, ExportButton } from "ra-ui-materialui";
import { DeviceRemoveButton } from "./devices";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
large: {
height: "120px",
width: "120px",
float: "right",
},
});
const UserListActions = ({
currentSort,
className,
resource,
filters,
displayedFilters,
exporter, // you can hide ExportButton if exporter = (null || false)
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
basePath,
selectedIds,
onUnselectItems,
showFilter,
maxResults,
total,
...rest
}) => (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<CreateButton basePath={basePath} />
<ExportButton
disabled={total === 0}
resource={resource}
sort={currentSort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
</TopToolbar>
);
const UserPagination = props => ( const UserPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
@@ -100,7 +36,6 @@ const UserPagination = props => (
const UserFilter = props => ( const UserFilter = props => (
<Filter {...props}> <Filter {...props}>
<SearchInput source="user_id" alwaysOn />
<BooleanInput source="guests" alwaysOn /> <BooleanInput source="guests" alwaysOn />
<BooleanInput <BooleanInput
label="resources.users.fields.show_deactivated" label="resources.users.fields.show_deactivated"
@@ -110,119 +45,65 @@ const UserFilter = props => (
</Filter> </Filter>
); );
const UserBulkActionButtons = props => { const ListActions = props => {
const translate = useTranslate(); const {
className,
basePath,
total,
resource,
currentSort,
filterValues,
exporter
} = props;
return ( return (
<Fragment> <TopToolbar className={className}>
<ServerNoticeBulkButton {...props} /> <CreateButton basePath={basePath} />
<BulkDeleteButton <ImportButton {...props} />
{...props} <ExportButton
label="resources.users.action.erase" disabled={total === 0}
title={translate("resources.users.helper.erase")} resource={resource}
sort={currentSort}
filter={filterValues}
exporter={exporter}
/> />
</Fragment> </TopToolbar>
); );
}; };
const AvatarField = ({ source, className, record = {} }) => ( export const UserList = props => (
<Avatar src={record[source]} className={className} />
);
export const UserList = props => {
const classes = useStyles();
return (
<List <List
{...props} {...props}
filters={<UserFilter />} filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }} filterDefaultValues={{ guests: true, deactivated: false }}
actions={<UserListActions maxResults={10000} />} bulkActionButtons={false}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />} pagination={<UserPagination />}
actions={<ListActions />}
> >
<Datagrid rowClick="edit"> <Datagrid rowClick="edit">
<AvatarField <ReferenceField
source="avatar_src" source="Avatar"
reference="users"
link={false}
sortable={false} sortable={false}
className={classes.small} >
/> <ImageField source="avatar_url" title="displayname" />
<TextField source="id" sortable={false} /> </ReferenceField>
<TextField source="id" />
{/* Hack since the users endpoint does not give displaynames in the list*/}
<ReferenceField
source="name"
reference="users"
link={false}
sortable={false}
>
<TextField source="displayname" /> <TextField source="displayname" />
</ReferenceField>
<BooleanField source="is_guest" sortable={false} /> <BooleanField source="is_guest" sortable={false} />
<BooleanField source="admin" sortable={false} /> <BooleanField source="admin" sortable={false} />
<BooleanField source="deactivated" sortable={false} /> <BooleanField source="deactivated" sortable={false} />
</Datagrid> </Datagrid>
</List> </List>
); );
};
function generateRandomUser() {
const homeserver = localStorage.getItem("home_server");
const user_id =
"@" +
Array(8)
.fill("0123456789abcdefghijklmnopqrstuvwxyz")
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] /
(0xffffffff + 1)) *
x.length
)
]
)
.join("") +
":" +
homeserver;
const password = Array(20)
.fill(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$"
)
.map(
x =>
x[
Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1)) *
x.length
)
]
)
.join("");
return {
id: user_id,
password: password,
};
}
// redirect to the related Author show page
const redirect = (basePath, id, data) => {
return {
pathname: "/showpdf",
state: {
id: data.id,
displayname: data.displayname,
password: data.password,
},
};
};
const UserCreateToolbar = props => (
<Toolbar {...props}>
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirect}
submitOnEnter={true}
/>
<SaveButton
label="synapseadmin.action.save_only"
redirect="list"
submitOnEnter={false}
variant="text"
/>
</Toolbar>
);
// https://matrix.org/docs/spec/appendices#user-identifiers // https://matrix.org/docs/spec/appendices#user-identifiers
const validateUser = regex( const validateUser = regex(
@@ -230,33 +111,9 @@ const validateUser = regex(
"synapseadmin.users.invalid_user_id" "synapseadmin.users.invalid_user_id"
); );
const UserEditToolbar = props => {
const translate = useTranslate();
return (
<Toolbar {...props}>
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirect}
submitOnEnter={true}
/>
<SaveButton
label="synapseadmin.action.save_only"
redirect="list"
submitOnEnter={false}
variant="text"
/>
<DeleteButton
label="resources.users.action.erase"
title={translate("resources.users.helper.erase")}
/>
<ServerNoticeButton />
</Toolbar>
);
};
export const UserCreate = props => ( export const UserCreate = props => (
<Create record={generateRandomUser()} {...props}> <Create {...props}>
<SimpleForm toolbar={<UserCreateToolbar />}> <SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} /> <TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" /> <PasswordInput source="password" autoComplete="new-password" />
@@ -277,33 +134,10 @@ export const UserCreate = props => (
</Create> </Create>
); );
const UserTitle = ({ record }) => { export const UserEdit = props => (
const translate = useTranslate(); <Edit {...props}>
return ( <TabbedForm>
<span> <FormTab label="resources.users.name" icon={<PersonPinIcon />}>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = props => {
const classes = useStyles();
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab
label={translate("resources.users.name", { smart_count: 1 })}
icon={<PersonPinIcon />}
>
<AvatarField
source="avatar_src"
sortable={false}
className={classes.large}
/>
<TextInput source="id" disabled /> <TextInput source="id" disabled />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" /> <PasswordInput source="password" autoComplete="new-password" />
@@ -312,25 +146,6 @@ export const UserEdit = props => {
source="deactivated" source="deactivated"
helperText="resources.users.helper.deactivate" helperText="resources.users.helper.deactivate"
/> />
<DateField
source="creation_ts_ms"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
/>
<TextField source="consent_version" />
</FormTab>
<FormTab
label="resources.users.threepid"
icon={<ContactMailIcon />}
path="threepid"
>
<ArrayInput source="threepids"> <ArrayInput source="threepids">
<SimpleFormIterator> <SimpleFormIterator>
<SelectInput <SelectInput
@@ -344,48 +159,11 @@ export const UserEdit = props => {
</SimpleFormIterator> </SimpleFormIterator>
</ArrayInput> </ArrayInput>
</FormTab> </FormTab>
<FormTab
label={translate("resources.devices.name", { smart_count: 2 })}
icon={<DevicesIcon />}
path="devices"
>
<ReferenceManyField
reference="devices"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField
source="last_seen_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<DeviceRemoveButton />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab <FormTab
label="resources.connections.name" label="resources.connections.name"
icon={<SettingsInputComponentIcon />} icon={<SettingsInputComponentIcon />}
path="connections"
>
<ReferenceField
reference="connections"
source="id"
addLabel={false}
link={false}
> >
<ReferenceField reference="connections" source="id" addLabel={false}>
<ArrayField <ArrayField
source="devices[].sessions[0].connections" source="devices[].sessions[0].connections"
label="resources.connections.name" label="resources.connections.name"
@@ -417,4 +195,3 @@ export const UserEdit = props => {
</TabbedForm> </TabbedForm>
</Edit> </Edit>
); );
};
+2 -119
View File
@@ -4,39 +4,13 @@ export default {
...germanMessages, ...germanMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Heimserver URL", homeserver: "Heimserver",
welcome: "Willkommen bei Synapse-admin", welcome: "Willkommen bei Synapse-admin",
server_version: "Synapse Version",
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
url_error: "Keine gültige Matrix Server URL",
},
action: {
save_and_show: "Speichern und QR Code erzeugen",
save_only: "Nur speichern",
download_pdf: "PDF speichern",
}, },
users: { users: {
invalid_user_id: invalid_user_id:
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver", "Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
}, },
rooms: {
details: "Raumdetails",
room_name: "Raumname",
make_public: "Öffentlicher Raum",
encrypt: "Verschlüsselter Raum",
room_name_required: "Muss angegeben werden",
alias_required_if_public: "Muss für öffentliche Räume angegeben werden.",
alias: "Alias",
alias_too_long:
"Darf zusammen mit der Domain des Homeservers 255 bytes nicht überschreiten",
tabs: {
basic: "Allgemein",
members: "Mitglieder",
detail: "Details",
permission: "Berechtigungen",
},
},
}, },
resources: { resources: {
users: { users: {
@@ -44,13 +18,12 @@ export default {
name: "Benutzer", name: "Benutzer",
email: "E-Mail", email: "E-Mail",
msisdn: "Telefon", msisdn: "Telefon",
threepid: "E-Mail / Telefon",
fields: { fields: {
avatar: "Avatar", avatar: "Avatar",
id: "Benutzer-ID", id: "Benutzer-ID",
name: "Name", name: "Name",
is_guest: "Gast", is_guest: "Gast",
admin: "Server Administrator", admin: "Admin",
deactivated: "Deaktiviert", deactivated: "Deaktiviert",
guests: "Zeige Gäste", guests: "Zeige Gäste",
show_deactivated: "Zeige deaktivierte Benutzer", show_deactivated: "Zeige deaktivierte Benutzer",
@@ -58,24 +31,12 @@ export default {
displayname: "Anzeigename", displayname: "Anzeigename",
password: "Passwort", password: "Passwort",
avatar_url: "Avatar URL", avatar_url: "Avatar URL",
avatar_src: "Avatar",
medium: "Medium", medium: "Medium",
threepids: "3PIDs", threepids: "3PIDs",
address: "Adresse", address: "Adresse",
creation_ts_ms: "Zeitpunkt der Erstellung",
consent_version: "Zugestimmte Geschäftsbedingungen",
// Devices:
device_id: "Geräte-ID",
display_name: "Gerätename",
last_seen_ts: "Zeitstempel",
last_seen_ip: "IP-Adresse",
}, },
helper: { helper: {
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.", deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
},
action: {
erase: "Lösche Benutzerdaten",
}, },
}, },
rooms: { rooms: {
@@ -85,38 +46,6 @@ export default {
name: "Name", name: "Name",
canonical_alias: "Alias", canonical_alias: "Alias",
joined_members: "Mitglieder", joined_members: "Mitglieder",
invite_members: "Mitglieder einladen",
invitees: "Einladungen",
joined_local_members: "Lokale Mitglieder",
state_events: "Ereignisse",
version: "Version",
is_encrypted: "Verschlüsselt",
encryption: "Verschlüsselungs-Algorithmus",
federatable: "Fö­de­rierbar",
public: "Öffentlich",
creator: "Ersteller",
join_rules: "Beitrittsregeln",
guest_access: "Gastzugriff",
history_visibility: "Historie-Sichtbarkeit",
},
enums: {
join_rules: {
public: "Öffentlich",
knock: "Auf Anfrage",
invite: "Nur auf Einladung",
private: "Privat",
},
guest_access: {
can_join: "Gäste können beitreten",
forbidden: "Gäste können nicht beitreten",
},
history_visibility: {
invited: "Ab Einladung",
joined: "Ab Beitritt",
shared: "Ab Setzen der Einstellung",
world_readable: "Jeder",
},
unencrypted: "Nicht verschlüsselt",
}, },
}, },
connections: { connections: {
@@ -127,51 +56,5 @@ export default {
user_agent: "User Agent", user_agent: "User Agent",
}, },
}, },
devices: {
name: "Gerät |||| Geräte",
action: {
erase: {
title: "Entferne %{id}",
content: 'Möchten Sie das Gerät "%{name}" wirklich entfernen?',
success: "Gerät erfolgreich entfernt.",
failure: "Beim Entfernen ist ein Fehler aufgetreten.",
},
},
},
servernotices: {
name: "Serverbenachrichtigungen",
send: "Servernachricht versenden",
fields: {
body: "Nachricht",
},
action: {
send: "Sende Nachricht",
send_success: "Nachricht erfolgreich versendet.",
send_failure: "Beim Versenden ist ein Fehler aufgetreten.",
},
helper: {
send:
'Sendet eine Serverbenachrichtigung an die ausgewählten Nutzer. Hierfür muss das Feature "Server Notices" auf dem Server aktiviert sein.',
},
},
},
ra: {
...germanMessages.ra,
auth: {
...germanMessages.ra.auth,
auth_check_error: "Anmeldung fehlgeschlagen",
},
input: {
...germanMessages.ra.input,
password: {
...germanMessages.ra.input.password,
toggle_hidden: "Anzeigen",
toggle_visible: "Verstecken",
},
},
notification: {
...germanMessages.ra.notifiaction,
logged_out: "Abgemeldet",
},
}, },
}; };
+2 -100
View File
@@ -4,39 +4,13 @@ export default {
...englishMessages, ...englishMessages,
synapseadmin: { synapseadmin: {
auth: { auth: {
base_url: "Homeserver URL", homeserver: "Homeserver",
welcome: "Welcome to Synapse-admin", welcome: "Welcome to Synapse-admin",
server_version: "Synapse version",
username_error: "Please enter fully qualified user ID: '@user:domain'",
protocol_error: "URL has to start with 'http://' or 'https://'",
url_error: "Not a valid Matrix server URL",
},
action: {
save_and_show: "Create QR code",
save_only: "Save",
download_pdf: "Download PDF",
}, },
users: { users: {
invalid_user_id: invalid_user_id:
"Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver", "Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver",
}, },
rooms: {
details: "Room Details",
room_name: "Room Name",
make_public: "Make room public",
encrypt: "Encrypt room",
room_name_required: "Must be provided",
alias_required_if_public: "Must be provided for a public room",
alias: "Alias",
alias_too_long:
"Must not exceed 255 bytes including the domain of the homeserver.",
tabs: {
basic: "Basic",
members: "Members",
detail: "Details",
permission: "Permissions",
},
},
}, },
resources: { resources: {
users: { users: {
@@ -44,13 +18,12 @@ export default {
name: "User |||| Users", name: "User |||| Users",
email: "Email", email: "Email",
msisdn: "Phone", msisdn: "Phone",
threepid: "Email / Phone",
fields: { fields: {
avatar: "Avatar", avatar: "Avatar",
id: "User-ID", id: "User-ID",
name: "Name", name: "Name",
is_guest: "Guest", is_guest: "Guest",
admin: "Server Administrator", admin: "Admin",
deactivated: "Deactivated", deactivated: "Deactivated",
guests: "Show guests", guests: "Show guests",
show_deactivated: "Show deactivated users", show_deactivated: "Show deactivated users",
@@ -58,24 +31,12 @@ export default {
displayname: "Displayname", displayname: "Displayname",
password: "Password", password: "Password",
avatar_url: "Avatar URL", avatar_url: "Avatar URL",
avatar_src: "Avatar",
medium: "Medium", medium: "Medium",
threepids: "3PIDs", threepids: "3PIDs",
address: "Address", address: "Address",
creation_ts_ms: "Creation timestamp",
consent_version: "Consent version",
// Devices:
device_id: "Device-ID",
display_name: "Device name",
last_seen_ts: "Timestamp",
last_seen_ip: "IP address",
}, },
helper: { helper: {
deactivate: "Deactivated users cannot be reactivated", deactivate: "Deactivated users cannot be reactivated",
erase: "Mark the user as GDPR-erased",
},
action: {
erase: "Erase user data",
}, },
}, },
rooms: { rooms: {
@@ -85,38 +46,6 @@ export default {
name: "Name", name: "Name",
canonical_alias: "Alias", canonical_alias: "Alias",
joined_members: "Members", joined_members: "Members",
invite_members: "Invite Members",
invitees: "Invitations",
joined_local_members: "local members",
state_events: "State events",
version: "Version",
is_encrypted: "Encrypted",
encryption: "Encryption",
federatable: "Federatable",
public: "Public",
creator: "Creator",
join_rules: "Join rules",
guest_access: "Guest access",
history_visibility: "History visibility",
},
enums: {
join_rules: {
public: "Public",
knock: "Knock",
invite: "Invite",
private: "Private",
},
guest_access: {
can_join: "Guests can join",
forbidden: "Guests can not join",
},
history_visibility: {
invited: "Since invited",
joined: "Since joined",
shared: "Since shared",
world_readable: "Anyone",
},
unencrypted: "Unencrypted",
}, },
}, },
connections: { connections: {
@@ -127,32 +56,5 @@ export default {
user_agent: "User agent", user_agent: "User agent",
}, },
}, },
devices: {
name: "Device |||| Devices",
action: {
erase: {
title: "Removing %{id}",
content: 'Are you sure you want to remove the device "%{name}"?',
success: "Device successfully removed.",
failure: "An error has occurred.",
},
},
},
servernotices: {
name: "Server Notices",
send: "Send server notices",
fields: {
body: "Message",
},
action: {
send: "Send note",
send_success: "Server notice successfully sent.",
send_failure: "An error has occurred.",
},
helper: {
send:
'Sends a server notice to the selected users. The feature "Server Notices" has to be activated at the server.',
},
},
}, },
}; };
-2
View File
@@ -1,6 +1,4 @@
import { configure } from "enzyme"; import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16"; import Adapter from "enzyme-adapter-react-16";
import fetchMock from "jest-fetch-mock";
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
fetchMock.enableMocks();
+25 -9
View File
@@ -1,8 +1,23 @@
import { fetchUtils } from "react-admin"; import { fetchUtils } from "react-admin";
const ensureHttpsForUrl = url => {
if (/^https:\/\//i.test(url)) {
return url;
}
const domain = url.replace(/http.?:\/\//g, "");
return "https://" + domain;
};
const stripTrailingSlash = str => {
if (!str) {
return;
}
return str.endsWith("/") ? str.slice(0, -1) : str;
};
const authProvider = { const authProvider = {
// called when the user attempts to log in // called when the user attempts to log in
login: ({ base_url, username, password }) => { login: ({ homeserver, username, password }) => {
console.log("login "); console.log("login ");
const options = { const options = {
method: "POST", method: "POST",
@@ -13,16 +28,17 @@ const authProvider = {
}), }),
}; };
// use the base_url from login instead of the well_known entry from the const url = window.decodeURIComponent(homeserver);
// server, since the admin might want to access the admin API via some const trimmed_url = url.trim().replace(/\s/g, "");
// private address const login_api_url =
localStorage.setItem("base_url", base_url); ensureHttpsForUrl(trimmed_url) + "/_matrix/client/r0/login";
const decoded_base_url = window.decodeURIComponent(base_url);
const login_api_url = decoded_base_url + "/_matrix/client/r0/login";
return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => { return fetchUtils.fetchJson(login_api_url, options).then(({ json }) => {
localStorage.setItem("home_server", json.home_server); const normalized_base_url = stripTrailingSlash(
json.well_known["m.homeserver"].base_url
);
localStorage.setItem("base_url", normalized_base_url);
localStorage.setItem("home_server_url", json.home_server);
localStorage.setItem("user_id", json.user_id); localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token); localStorage.setItem("access_token", json.access_token);
localStorage.setItem("device_id", json.device_id); localStorage.setItem("device_id", json.device_id);
+45 -178
View File
@@ -14,43 +14,22 @@ const jsonClient = (url, options = {}) => {
return fetchUtils.fetchJson(url, options); return fetchUtils.fetchJson(url, options);
}; };
const mxcUrlToHttp = mxcUrl => {
const homeserver = localStorage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
var ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) return null;
const serverName = ret[1];
const mediaId = ret[2];
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const resourceMap = { const resourceMap = {
users: { users: {
path: "/_synapse/admin/v2/users", path: "/_synapse/admin/v2/users",
map: u => ({ map: u => ({
...u, ...u,
id: u.name, id: u.name,
avatar_src: mxcUrlToHttp(u.avatar_url),
is_guest: !!u.is_guest, is_guest: !!u.is_guest,
admin: !!u.admin, admin: !!u.admin,
deactivated: !!u.deactivated, deactivated: !!u.deactivated,
displayname: u.display_name || u.displayname,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}), }),
data: "users", data: "users",
total: json => json.total, total: (json, from, perPage) => {
create: data => ({ return json.next_token
endpoint: `/_synapse/admin/v2/users/${data.id}`, ? parseInt(json.next_token, 10) + perPage
body: data, : from + json.users.length;
method: "PUT", },
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/deactivate/${params.id}`,
body: { erase: true },
method: "POST",
}),
}, },
rooms: { rooms: {
path: "/_synapse/admin/v1/rooms", path: "/_synapse/admin/v1/rooms",
@@ -59,50 +38,12 @@ const resourceMap = {
id: r.room_id, id: r.room_id,
alias: r.canonical_alias, alias: r.canonical_alias,
members: r.joined_members, members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}), }),
data: "rooms", data: "rooms",
total: json => json.total_rooms, total: json => {
create: data => ({ return json.total_rooms;
endpoint: "/_matrix/client/r0/createRoom",
body: {
name: data.name,
room_alias_name: data.canonical_alias,
visibility: data.public ? "public" : "private",
invite:
Array.isArray(data.invitees) && data.invitees.length > 0
? data.invitees
: undefined,
initial_state: data.encrypt
? [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
}, },
}, },
]
: undefined,
},
method: "POST",
}),
},
devices: {
map: d => ({
...d,
id: d.device_id,
}),
data: "devices",
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${id}/devices`,
}),
delete: params => ({
endpoint: `/_synapse/admin/v2/users/${params.user_id}/devices/${params.id}`,
}),
},
connections: { connections: {
path: "/_synapse/admin/v1/whois", path: "/_synapse/admin/v1/whois",
map: c => ({ map: c => ({
@@ -111,29 +52,6 @@ const resourceMap = {
}), }),
data: "connections", data: "connections",
}, },
room_members: {
map: m => ({
id: m,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
},
servernotices: {
map: n => ({ id: n.event_id }),
create: data => ({
endpoint: "/_synapse/admin/v1/send_server_notice",
body: {
user_id: data.id,
content: {
msgtype: "m.text",
body: data.body,
},
},
method: "POST",
}),
},
}; };
function filterNullValues(key, value) { function filterNullValues(key, value) {
@@ -144,20 +62,11 @@ function filterNullValues(key, value) {
return value; return value;
} }
function getSearchOrder(order) {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider = { const dataProvider = {
getList: (resource, params) => { getList: (resource, params) => {
console.log("getList " + resource); console.log("getList " + resource);
const { user_id, guests, deactivated } = params.filter; const { user_id, guests, deactivated } = params.filter;
const { page, perPage } = params.pagination; const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage; const from = (page - 1) * perPage;
const query = { const query = {
from: from, from: from,
@@ -165,16 +74,14 @@ const dataProvider = {
user_id: user_id, user_id: user_id,
guests: guests, guests: guests,
deactivated: deactivated, deactivated: deactivated,
order_by: field,
dir: getSearchOrder(order),
}; };
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const homeserver_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`; const url = `${homeserver_url}?${stringify(query)}`;
return jsonClient(url).then(({ json }) => ({ return jsonClient(url).then(({ json }) => ({
data: json[res.data].map(res.map), data: json[res.data].map(res.map),
@@ -189,8 +96,8 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const homeserver_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`).then(({ json }) => ({ return jsonClient(`${homeserver_url}/${params.id}`).then(({ json }) => ({
data: res.map(json), data: res.map(json),
})); }));
}, },
@@ -202,27 +109,39 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const homeserver_url = homeserver + res.path;
return Promise.all( return Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`)) params.ids.map(id => jsonClient(`${homeserver_url}/${id}`))
).then(responses => ({ ).then(responses => ({
data: responses.map(({ json }) => res.map(json)), data: responses.map(({ json }) => res.map(json)),
})); }));
}, },
getManyReference: (resource, params) => { getManyReference: (resource, params) => {
// FIXME
console.log("getManyReference " + resource); console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const query = {
sort: JSON.stringify([field, order]),
range: JSON.stringify([(page - 1) * perPage, page * perPage - 1]),
filter: JSON.stringify({
...params.filter,
[params.target]: params.id,
}),
};
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource]; const res = resourceMap[resource];
const ref = res["reference"](params.id); const homeserver_url = homeserver + res.path;
const endpoint_url = homeserver + ref.endpoint; const url = `${homeserver_url}?${stringify(query)}`;
return jsonClient(endpoint_url).then(({ headers, json }) => ({ return jsonClient(url).then(({ headers, json }) => ({
data: json[res.data].map(res.map), data: json,
total: parseInt(headers.get("content-range").split("/").pop(), 10),
})); }));
}, },
@@ -233,8 +152,8 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const homeserver_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.data.id}`, { return jsonClient(`${homeserver_url}/${params.data.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
@@ -249,9 +168,9 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const endpoint_url = homeserver + res.path; const homeserver_url = homeserver + res.path;
return Promise.all( return Promise.all(
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`), { params.ids.map(id => jsonClient(`${homeserver_url}/${id}`), {
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
}) })
@@ -266,41 +185,16 @@ const dataProvider = {
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource]; const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
const create = res["create"](params.data); const homeserver_url = homeserver + res.path;
const endpoint_url = homeserver + create.endpoint; return jsonClient(`${homeserver_url}/${params.data.id}`, {
return jsonClient(endpoint_url, { method: "PUT",
method: create.method, body: JSON.stringify(params.data, filterNullValues),
body: JSON.stringify(create.body, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
data: res.map(json), data: res.map(json),
})); }));
}, },
createMany: (resource, params) => {
console.log("createMany " + resource);
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
return Promise.all(
params.ids.map(id => {
params.data.id = id;
const cre = res["create"](params.data);
const endpoint_url = homeserver + cre.endpoint;
return jsonClient(endpoint_url, {
method: cre.method,
body: JSON.stringify(cre.body, filterNullValues),
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
},
delete: (resource, params) => { delete: (resource, params) => {
console.log("delete " + resource); console.log("delete " + resource);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
@@ -308,24 +202,12 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
if ("delete" in res) { const homeserver_url = homeserver + res.path;
const del = res["delete"](params); return jsonClient(`${homeserver_url}/${params.id}`, {
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
}).then(({ json }) => ({
data: json,
}));
} else {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
data: json, data: json,
})); }));
}
}, },
deleteMany: (resource, params) => { deleteMany: (resource, params) => {
@@ -335,32 +217,17 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
if ("delete" in res) { const homeserver_url = homeserver + res.path;
return Promise.all(
params.ids.map(id => {
const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
} else {
const endpoint_url = homeserver + res.path;
return Promise.all( return Promise.all(
params.ids.map(id => params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, { jsonClient(`${homeserver_url}/${id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(params.data, filterNullValues),
}) }).then(responses => ({
)
).then(responses => ({
data: responses.map(({ json }) => json), data: responses.map(({ json }) => json),
})); }))
} )
);
}, },
}; };
-78
View File
@@ -1,78 +0,0 @@
import dataProvider from "./dataProvider";
beforeEach(() => {
fetch.resetMocks();
});
describe("dataProvider", () => {
localStorage.setItem("base_url", "http://localhost");
localStorage.setItem("access_token", "access_token");
it("fetches all users", async () => {
fetch.mockResponseOnce(
JSON.stringify({
users: [
{
name: "user_id1",
password_hash: "password_hash1",
is_guest: 0,
admin: 0,
user_type: null,
deactivated: 0,
displayname: "User One",
},
{
name: "user_id2",
password_hash: "password_hash2",
is_guest: 0,
admin: 1,
user_type: null,
deactivated: 0,
displayname: "User Two",
},
],
next_token: "100",
total: 200,
})
);
const users = await dataProvider.getList("users", {
pagination: { page: 1, perPage: 5 },
sort: { field: "title", order: "ASC" },
filter: { author_id: 12 },
});
expect(users["data"][0]["id"]).toEqual("user_id1");
expect(users["total"]).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("fetches one user", async () => {
fetch.mockResponseOnce(
JSON.stringify({
name: "user_id1",
password: "user_password",
displayname: "User",
threepids: [
{
medium: "email",
address: "user@mail_1.com",
},
{
medium: "email",
address: "user@mail_2.com",
},
],
avatar_url: "mxc://localhost/user1",
admin: false,
deactivated: false,
})
);
const user = await dataProvider.getOne("users", { id: "user_id1" });
expect(user["data"]["id"]).toEqual("user_id1");
expect(user["data"]["displayname"]).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1);
});
});
+1594 -1708
View File
File diff suppressed because it is too large Load Diff