48 Commits

Author SHA1 Message Date
Manuel Stahl
fb8cff3e3e Merge tag '0.5.0' into amp.chat
Change-Id: I410e194bc7b153c69e00f40a4486a46924cd510a
2020-09-03 09:08:01 +02:00
Manuel Stahl
8db881a64d Use current node image as builder
Change-Id: I2bccf5bcd21e37625477695ee927d49dd4333ac4
2020-08-25 17:19:17 +02:00
Manuel Stahl
c09e59d47a yarn: Upgrade packages
- babel: 7.11

Change-Id: I6eebc1afbf54b6ce72e1b534f240e3e0aeb1c30a
2020-08-25 17:19:17 +02:00
Michael Albert
725e24d944 Add credentials to PDF
Fix Umlauts in PDF
Reorder elements of PDF

Change-Id: I49335584ef282e4b960275013ea7d16053b9f773
2020-08-24 07:57:40 +00:00
Dirk Klimpel
8bdf76f27e Bugfix plural in user tab (#62) 2020-08-11 10:25:05 +02:00
Michael Albert
7c9a87bc86 Show room members in room detail view
Change-Id: I7d82f728de0e503d4834f6815bde000c7a0204a3
2020-08-07 21:29:58 +02:00
Manuel Stahl
1f56bac356 Add basic tests for dataProvider
Change-Id: Ib399cbb4e927ab18f714371e07606df83170df52
2020-07-30 13:52:17 +02:00
Manuel Stahl
dd00a76603 Merge tag '0.4.2' into amp.chat
Change-Id: Id12309f0a4d3ff9983325e69131d5eebe5bd0bde
2020-07-30 12:56:20 +02:00
Manuel Stahl
bbbca0c57c Add git context to docker, so we can derive the release version
"git describe --tags" requires the git context.

Change-Id: I2bc5dde056c2ac480513004fb99336397355af30
2020-07-29 17:49:30 +02:00
Manuel Stahl
314906657f Add support to remove user devices (#57)
Change-Id: I19176daa656b9280ccd00f1ca0095e72870ca21e
2020-07-29 17:49:30 +02:00
Manuel Stahl
2915fd3e5b Merge tag '0.4.1' into amp.chat
Change-Id: I44c9f00e5aa7abe413f8a819e1143bebc4f08ce2
2020-07-28 15:09:48 +02:00
Manuel Stahl
78e7c5f391 Fix translation of user devices
Fixes #58.

Change-Id: Ic2f91917310fd1ba59636d06c81c338ca9dd297e
2020-07-23 09:24:18 +02:00
Michael Albert
1074178e31 Rename Admin -> Server Administrator
Change-Id: Ic11539252af553dbb7cca37996f2402669a9c0e4
2020-07-21 11:52:13 +02:00
Manuel Stahl
352ab1290a Add devices tab to UserEdit component
Allows to view user devices.
API was added by synapse v1.15.0.

Change-Id: Id0693bf6cd6f6182c657412cf8036537e2db9df7
2020-07-13 10:32:13 +02:00
Manuel Stahl
12447b7708 Increase export of users up to 10000
Change-Id: I54c7a52ae35aeb311074f0f3b103a2fdb92aaedd
2020-07-13 10:20:24 +02:00
Manuel Stahl
cd4efb7c07 yarn: Upgrade packages
- react-admin:  3.7.0

Change-Id: I01e4a2244a6ca0d6fc231fe83a94f73d48618824
2020-07-13 10:20:03 +02:00
Michael Albert
ff59ee4c2e Hide some room list information by default
Change-Id: Ic6fbf0d941d2ffcc87fb5b7793517b96792ce16d
2020-07-08 11:19:09 +02:00
Manuel Stahl
61938405e9 Add room detail view
Needs Synapse v1.14.0 or later

Change-Id: I6e3956a1e02fad5ba2f847458cd184af6aaedef0
2020-07-08 10:46:49 +02:00
Manuel Stahl
ac0657c428 Add hint about required synapse version
Change-Id: I8152681d695ded78d0f9b9d82bc038aa67723fbc
2020-07-08 09:55:52 +02:00
Michael Albert
ab709aee3e Add screenshots and install instructions
Change-Id: Ibfede12d700924b7b3b01af5f3a3624f21a93862
2020-07-08 07:49:42 +00:00
dklimpel
6da3c8b885 Bugfix translation of plural in UserTitle 2020-07-08 08:50:00 +02:00
Manuel Stahl
ab04db5baf Get avatar_url and displayname from v2/users API
API was added by synapse v1.13.0.

Change-Id: I927b81882fa20e5b3de3d9fc216e2136f7036bba
2020-07-07 18:51:35 +02:00
Dirk Klimpel
8282a3caf8 Move threepids in UserEdit to a separate tab (#51)
Separates information into individual tabs for a better overview.
2020-07-06 12:35:26 +02:00
Michael Albert
2fc75cd6fc Merge pull request #43 from dklimpel/extend_room_list
Extend the room list with further attributes
2020-07-03 19:32:00 +02:00
Dirk Klimpel
3fd615943c Shows encrypted status with icons 2020-07-01 22:36:15 +02:00
dklimpel
aaf1ebb909 Change field creation_ts * 1000 to creation_ts_ms 2020-06-16 10:15:22 +02:00
dklimpel
627f3d2917 Add creation timestamp and consent version to UserEdit
Add information about the user to UserEdit
- creation timestamp
- consent version
2020-06-16 10:15:22 +02:00
dklimpel
168e249296 Bugfix removes the ability to click on individual connections.
If you click on a connection in UserEdit, you will get an empty page.
This solves the problem.
2020-06-16 09:45:43 +02:00
dklimpel
5bdfb80db7 Bugfix sort users by user_id
Users are not sortable by `user_id`.
Set `sortable={false}`.
2020-06-10 13:11:35 +02:00
Michael Albert
a4662c2557 Translate room info
Change-Id: I7f3121da3c910592ecfcb4bca9dee34f2757f567
2020-06-10 07:18:58 +00:00
dependabot[bot]
b7c3684b80 Bump websocket-extensions from 0.1.3 to 0.1.4 (#49)
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-06-09 14:50:58 +02:00
Michael Albert
f6ca169fbc Fix data provider
Change-Id: Id1c929f593833ed35327e70d1d0dc8182a4b7306
2020-05-25 21:20:49 +02:00
Manuel Stahl
0ada5287d7 Show Synapse server version on login page
Change-Id: Id9b0d2adf83590524069d308f4fe9e5d14147295
2020-05-25 13:29:15 +02:00
Michael Albert
07862591fd Possibility to encrypt new rooms
Change-Id: Ie415a0f8ecec646510ac8f2f0adca58064e30da5
2020-05-25 13:25:46 +02:00
dklimpel
3884c50012 Extend the room list with further attributes
Add further attributes:
- is_encrypted
- federatable
- public
- state_events
- version
- joined_local_members

Also add the ability to sort.

API was added by synapse v1.13.0.
2020-05-23 17:43:33 +02:00
Michael Albert
300e22a537 Show version of Synapse-Admin
Change-Id: I354e3f3b3e4f45e5ca72318ce70b66bee433f3d6
2020-05-15 13:28:51 +02:00
Dirk Klimpel
009ce803e2 Add ServerNoticeButton to UserBulkActionButtons (#41)
This adds the button to send "Server Notices" to many users at once.
2020-05-06 09:03:33 +02:00
Manuel Stahl
ab649fbf70 Merge branch 'master' into amp.chat
Change-Id: I6141964157bcb7218e2e6368a3ca8d20eb4855e9
2020-05-05 13:44:06 +02:00
Manuel Stahl
c41b8ab846 Add ServerNoticeButton to UserEditToolbar
For this, the feature "Server Notices" must be activated on the server.

Change-Id: If3873dc5548822a06a7be0c55e48835c9fb8f78f
2020-05-05 13:34:16 +02:00
Dirk Klimpel
7f16f784f9 Allow port in homeserver URL (#40)
Allow to use homeserver URL with port (e.g. ':443').
2020-05-04 21:28:04 +02:00
Michael Albert
1002b6464a Build with relative paths
Change-Id: I50e47cf4d5a68fa84972c1f205eb066d5df3f9db
2020-05-04 18:28:06 +02:00
Michael Albert
50b770a312 Extract homeserver URL from fully qualified user id
Also lookup the .well-known entry and use it if available.

Change-Id: I609046f01860fd5e3ba8cb801006e6098a4ad840
2020-05-02 16:24:53 +02:00
Manuel Stahl
2d0ce50444 Save base_url from login input
Change-Id: I58447145dfc2df4ab3544b6a165721f900e29b24
2020-05-02 16:22:53 +02:00
Manuel Stahl
1fb89c9e58 Add missing german translations
Change-Id: I297a730f73a4a4aa47a4ce679bd13ef0af69cc38
2020-05-02 16:22:53 +02:00
Michael Albert
8a4c0fe0fe Use input components for LoginPage
Change-Id: Icaaa579eaeaaafe183fb027e4d3bf206f8f5516a
2020-05-02 16:22:53 +02:00
Michael Albert
dd022eab04 Validate URL on input instead of automatic rewrite of http to https
Change-Id: I3f3a9c5fb408af1f03ef876456133b331dc4cea3
2020-05-02 16:22:40 +02:00
Timo Paulssen
880223e5de Offer invitations in room creation
Turns the "Create Room" form into a tabbed form with
tabs that mimic the room display. In the "Members" tab
an AutocompleteArrayInput allows selecting multiple
users by their displayname.

The displayname is also what is displayed ìn the
invitations list.

Creating the room immediately sends out the invitations
as well.

Change-Id: I3915144114ffe4c629848363c9cb7917634d04d8
2020-04-28 19:36:15 +02:00
Manuel Stahl
437fd70d6d Make creating users a special case in dataProvider
Since users are created with PUT instead of POST, this is actually a
special case.

Change-Id: Ibe430fcac4d81de9723abd650804ffa93f87bf6d
2020-04-23 16:57:24 +02:00
24 changed files with 3070 additions and 1853 deletions

View File

@@ -1,5 +1,4 @@
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
.git/
tests/
build/
lib/

View File

@@ -1,5 +1,5 @@
# Builder
FROM node:10-alpine as builder
FROM node:current as builder
WORKDIR /src

View File

@@ -4,6 +4,33 @@
This project is built using [react-admin](https://marmelab.com/react-admin/).
Use `yarn install` after cloning this repo.
It needs at least Synapse v1.18.0 for all functions to work as expected!
Use `yarn start` to launch the webserver.
## Step-By-Step install:
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

View File

@@ -1,9 +1,10 @@
{
"name": "synapse-admin",
"version": "0.1.0",
"version": "AMP/2020.08",
"description": "Admin GUI for the Matrix.org server Synapse",
"author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0",
"homepage": ".",
"repository": {
"type": "git",
"url": "https://github.com/Awesome-Technologies/synapse-admin"
@@ -11,28 +12,30 @@
"devDependencies": {
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^10.0.2",
"@testing-library/user-event": "^10.0.1",
"@testing-library/user-event": "^12.0.11",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-prettier": "^3.1.2",
"jest-fetch-mock": "^3.0.3",
"prettier": "^2.0.0"
},
"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",
"qrcode.react": "^1.0.0",
"ra-language-german": "^2.1.2",
"react": "^16.13.1",
"react-admin": "^3.4.0",
"react-admin": "^3.7.0",
"react-dom": "^16.13.1",
"react-scripts": "^3.4.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",
"build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build",
"fix:other": "yarn prettier --write",
"fix:code": "yarn test:lint --fix",
"fix": "yarn fix:code && yarn fix:other",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,32 @@
name="description"
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
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
@@ -38,5 +64,12 @@
To begin the development, run `npm start` or `yarn start`.
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>
</html>

BIN
screenshots.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -48,6 +48,9 @@ const App = () => (
icon={RoomIcon}
/>
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="servernotices" />
</Admin>
);

View File

@@ -1,13 +1,17 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
fetchUtils,
FormDataConsumer,
Notification,
useLogin,
useNotify,
useLocale,
useSetLocale,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { Field, Form } from "react-final-form";
import { Form, useForm } from "react-final-form";
import {
Avatar,
Button,
@@ -25,7 +29,7 @@ const useStyles = makeStyles(theme => ({
main: {
display: "flex",
flexDirection: "column",
minHeight: "100vh",
minHeight: "calc(100vh - 1em)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
@@ -34,8 +38,9 @@ const useStyles = makeStyles(theme => ({
backgroundSize: "cover",
},
card: {
minWidth: 300,
minWidth: "30em",
marginTop: "6em",
marginBottom: "6em",
},
avatar: {
margin: "1em",
@@ -60,6 +65,12 @@ const useStyles = makeStyles(theme => ({
actions: {
padding: "0 1em 1em 1em",
},
serverVersion: {
color: "#9e9e9e",
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
}));
const LoginPage = ({ theme }) => {
@@ -70,7 +81,7 @@ const LoginPage = ({ theme }) => {
var locale = useLocale();
const setLocale = useSetLocale();
const translate = useTranslate();
const homeserver = localStorage.getItem("base_url");
const base_url = localStorage.getItem("base_url");
const renderInput = ({
meta: { touched, error } = {},
@@ -88,15 +99,23 @@ const LoginPage = ({ theme }) => {
const validate = values => {
const errors = {};
if (!values.homeserver) {
errors.homeserver = translate("ra.validation.required");
}
if (!values.username) {
errors.username = translate("ra.validation.required");
}
if (!values.password) {
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;
};
@@ -115,9 +134,101 @@ 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 (
<Form
initialValues={{ homeserver: homeserver }}
initialValues={{ base_url: base_url }}
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
@@ -146,32 +257,9 @@ const LoginPage = ({ theme }) => {
<MenuItem value="en">English</MenuItem>
</Select>
</div>
<div className={classes.input}>
<Field
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>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
</div>
<CardActions className={classes.actions}>
<Button

View File

@@ -0,0 +1,148 @@
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>
);
};

View File

@@ -33,6 +33,8 @@ const ShowUserPdf = props => {
margin: "auto",
overflowX: "hidden",
overflowY: "hidden",
fontFamily: "DejaVu Sans, Sans-Serif",
fontSize: 15,
},
header: {
height: 144,
@@ -41,24 +43,41 @@ const ShowUserPdf = props => {
marginTop: 15,
},
name: {
width: 233,
fontSize: 40,
width: 240,
fontSize: 35,
float: "left",
marginTop: 15,
marginTop: 100,
},
logo: {
width: 90,
marginTop: 20,
marginRight: 32,
float: "left",
marginTop: 50,
marginRight: 70,
float: "right",
},
code: {
marginLeft: 330,
body: {
clear: "both",
},
table_cell: {
verticalAlign: "top",
},
code_note: {
marginLeft: 32,
marginTop: 86,
},
qr: {
marginRight: 40,
float: "right",
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,
@@ -78,16 +97,21 @@ const ShowUserPdf = props => {
var qrCode = "";
var displayname = "";
var id = "";
var password = "";
var username = "";
var serverUrl = "";
if (
props.location.state &&
props.location.state.id &&
props.location.state.password
) {
const { id, password } = props.location.state;
id = props.location.state.id;
password = props.location.state.password;
const username = id.substring(1, id.indexOf(":"));
const serverUrl = "https://" + id.substring(id.indexOf(":") + 1);
username = id.substring(1, id.indexOf(":"));
serverUrl = "https://" + id.substring(id.indexOf(":") + 1);
const qrString = calculateQrString(serverUrl, username, password);
@@ -109,19 +133,73 @@ const ShowUserPdf = props => {
ref={r => (resume = r)}
>
<div className={classes.page}>
<div className={classes.code}>Ihr persönlicher Anmeldecode:</div>
<div className={classes.header}>
<div className={classes.name}>{displayname}</div>
<img className={classes.logo} alt="Logo" src="images/logo.png" />
<div className={classes.qr}>{qrCode}</div>
</div>
<div className={classes.note}>
Hier können Sie Ihre selbst gewählte Schlüsselsicherungs-Passphrase
notieren:
<br />
<br />
<br />
<hr />
<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>

89
src/components/devices.js Normal file
View File

@@ -0,0 +1,89 @@
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>
);
};

View File

@@ -1,37 +1,65 @@
import React from "react";
import { connect } from "react-redux";
import {
BooleanField,
AutocompleteArrayInput,
BooleanInput,
BooleanField,
Create,
Datagrid,
Filter,
FormTab,
List,
Pagination,
ReferenceArrayField,
ReferenceArrayInput,
ReferenceField,
ReferenceManyField,
SelectField,
Show,
SimpleForm,
Tab,
TabbedForm,
TabbedShowLayout,
TextField,
TextInput,
useTranslate,
} from "react-admin";
import ViewListIcon from "@material-ui/icons/ViewList";
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 => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const RoomList = props => (
<List {...props} pagination={<RoomPagination />}>
<Datagrid rowClick="show">
<TextField source="room_id" />
<TextField source="name" />
<TextField source="canonical_alias" />
<TextField source="joined_members" />
</Datagrid>
</List>
);
const EncryptionField = ({ source, record = {}, emptyText }) => {
const translate = useTranslate();
const value = get(record, source);
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
@@ -41,7 +69,7 @@ const validateDisplayName = fieldval =>
: undefined;
function approximateAliasLength(alias, homeserver) {
/* TODO maybe handle punycode in homeserver URL */
/* TODO maybe handle punycode in homeserver name */
var te;
@@ -67,7 +95,7 @@ const validateAlias = fieldval => {
if (fieldval === undefined) {
return undefined;
}
const homeserver = localStorage.getItem("home_server_url");
const homeserver = localStorage.getItem("home_server");
if (approximateAliasLength(fieldval, homeserver) > 255) {
return "synapseadmin.rooms.alias_too_long";
@@ -100,20 +128,42 @@ const validateHasAliasIfPublic = formdata => {
export const RoomCreate = props => (
<Create {...props}>
<SimpleForm validate={validateHasAliasIfPublic}>
<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" />
</SimpleForm>
<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>
);
@@ -125,24 +175,189 @@ const RoomTitle = ({ record }) => {
</span>
);
};
export const RoomShow = props => (
<Show {...props} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.details" icon={<ViewListIcon />}>
<TextField source="id" disabled />
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="name" />
<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_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="canonical_alias" />
<TextField source="join_rules" />
<TextField source="guest_access" />
</Tab>
<Tab label="resources.rooms.fields.joined_members" icon={<UserIcon />}>
<ReferenceArrayField reference="users" source="members">
<Datagrid>
<TextField source="id" />
<TextField source="displayname" />
</Datagrid>
</ReferenceArrayField>
</Tab>
</TabbedShowLayout>
</Show>
);
<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>
</List>
);
};
function mapStateToProps(state) {
return {
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
};
}
export const RoomList = connect(mapStateToProps)(FilterableRoomList);

View File

@@ -1,5 +1,8 @@
import React, { Fragment } from "react";
import React, { cloneElement, Fragment } from "react";
import Avatar from "@material-ui/core/Avatar";
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 {
ArrayInput,
@@ -17,12 +20,12 @@ import {
FormTab,
BooleanField,
BooleanInput,
ImageField,
PasswordInput,
TextField,
TextInput,
SearchInput,
ReferenceField,
ReferenceManyField,
SelectInput,
BulkDeleteButton,
DeleteButton,
@@ -30,8 +33,66 @@ import {
regex,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
sanitizeListRestProps,
} from "react-admin";
import SaveQrButton from "./SaveQrButton";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
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 => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
@@ -53,6 +114,7 @@ const UserBulkActionButtons = props => {
const translate = useTranslate();
return (
<Fragment>
<ServerNoticeBulkButton {...props} />
<BulkDeleteButton
{...props}
label="resources.users.action.erase"
@@ -62,27 +124,39 @@ const UserBulkActionButtons = props => {
);
};
export const UserList = props => (
<List
{...props}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit">
<ImageField source="avatar_url" title="displayname" />
<TextField source="id" sortable={false} />
<TextField source="displayname" />
<BooleanField source="is_guest" sortable={false} />
<BooleanField source="admin" sortable={false} />
<BooleanField source="deactivated" sortable={false} />
</Datagrid>
</List>
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
export const UserList = props => {
const classes = useStyles();
return (
<List
{...props}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
actions={<UserListActions maxResults={10000} />}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit">
<AvatarField
source="avatar_src"
sortable={false}
className={classes.small}
/>
<TextField source="id" sortable={false} />
<TextField source="displayname" />
<BooleanField source="is_guest" sortable={false} />
<BooleanField source="admin" sortable={false} />
<BooleanField source="deactivated" sortable={false} />
</Datagrid>
</List>
);
};
function generateRandomUser() {
const homeserver = localStorage.getItem("home_server_url");
const homeserver = localStorage.getItem("home_server");
const user_id =
"@" +
Array(8)
@@ -175,6 +249,7 @@ const UserEditToolbar = props => {
label="resources.users.action.erase"
title={translate("resources.users.helper.erase")}
/>
<ServerNoticeButton />
</Toolbar>
);
};
@@ -206,49 +281,85 @@ const UserTitle = ({ record }) => {
const translate = useTranslate();
return (
<span>
{translate("resources.users.name")}{" "}
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = props => (
<Edit {...props} title={<UserTitle />}>
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label="resources.users.name" icon={<PersonPinIcon />}>
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
<BooleanInput source="admin" />
<BooleanInput
source="deactivated"
helperText="resources.users.helper.deactivate"
/>
<ArrayInput source="threepids">
<SimpleFormIterator>
<SelectInput
source="medium"
choices={[
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
]}
/>
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label="resources.connections.name"
icon={<SettingsInputComponentIcon />}
>
<ReferenceField reference="connections" source="id" addLabel={false}>
<ArrayField
source="devices[].sessions[0].connections"
label="resources.connections.name"
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="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
<BooleanInput source="admin" />
<BooleanInput
source="deactivated"
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">
<SimpleFormIterator>
<SelectInput
source="medium"
choices={[
{ id: "email", name: "resources.users.email" },
{ id: "msisdn", name: "resources.users.msisdn" },
]}
/>
<TextInput source="address" />
</SimpleFormIterator>
</ArrayInput>
</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="ip" sortable={false} />
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />
<DateField
source="last_seen"
source="last_seen_ts"
showTime
options={{
year: "numeric",
@@ -260,15 +371,50 @@ export const UserEdit = props => (
}}
sortable={false}
/>
<TextField
source="user_agent"
sortable={false}
style={{ width: "100%" }}
/>
<DeviceRemoveButton />
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
</TabbedForm>
</Edit>
);
</ReferenceManyField>
</FormTab>
<FormTab
label="resources.connections.name"
icon={<SettingsInputComponentIcon />}
path="connections"
>
<ReferenceField
reference="connections"
source="id"
addLabel={false}
link={false}
>
<ArrayField
source="devices[].sessions[0].connections"
label="resources.connections.name"
>
<Datagrid style={{ width: "100%" }}>
<TextField source="ip" sortable={false} />
<DateField
source="last_seen"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<TextField
source="user_agent"
sortable={false}
style={{ width: "100%" }}
/>
</Datagrid>
</ArrayField>
</ReferenceField>
</FormTab>
</TabbedForm>
</Edit>
);
};

View File

@@ -4,8 +4,12 @@ export default {
...germanMessages,
synapseadmin: {
auth: {
homeserver: "Heimserver",
base_url: "Heimserver URL",
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",
@@ -20,11 +24,18 @@ export default {
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: {
@@ -33,12 +44,13 @@ export default {
name: "Benutzer",
email: "E-Mail",
msisdn: "Telefon",
threepid: "E-Mail / Telefon",
fields: {
avatar: "Avatar",
id: "Benutzer-ID",
name: "Name",
is_guest: "Gast",
admin: "Admin",
admin: "Server Administrator",
deactivated: "Deaktiviert",
guests: "Zeige Gäste",
show_deactivated: "Zeige deaktivierte Benutzer",
@@ -46,9 +58,17 @@ export default {
displayname: "Anzeigename",
password: "Passwort",
avatar_url: "Avatar URL",
avatar_src: "Avatar",
medium: "Medium",
threepids: "3PIDs",
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: {
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
@@ -65,6 +85,38 @@ export default {
name: "Name",
canonical_alias: "Alias",
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: {
@@ -75,9 +127,40 @@ export default {
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: {
@@ -86,5 +169,9 @@ export default {
toggle_visible: "Verstecken",
},
},
notification: {
...germanMessages.ra.notifiaction,
logged_out: "Abgemeldet",
},
},
};

View File

@@ -4,8 +4,12 @@ export default {
...englishMessages,
synapseadmin: {
auth: {
homeserver: "Homeserver",
base_url: "Homeserver URL",
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",
@@ -20,11 +24,18 @@ export default {
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: {
@@ -33,12 +44,13 @@ export default {
name: "User |||| Users",
email: "Email",
msisdn: "Phone",
threepid: "Email / Phone",
fields: {
avatar: "Avatar",
id: "User-ID",
name: "Name",
is_guest: "Guest",
admin: "Admin",
admin: "Server Administrator",
deactivated: "Deactivated",
guests: "Show guests",
show_deactivated: "Show deactivated users",
@@ -46,9 +58,17 @@ export default {
displayname: "Displayname",
password: "Password",
avatar_url: "Avatar URL",
avatar_src: "Avatar",
medium: "Medium",
threepids: "3PIDs",
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: {
deactivate: "Deactivated users cannot be reactivated",
@@ -65,6 +85,38 @@ export default {
name: "Name",
canonical_alias: "Alias",
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: {
@@ -75,5 +127,32 @@ export default {
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.',
},
},
},
};

View File

@@ -1,4 +1,6 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import fetchMock from "jest-fetch-mock";
configure({ adapter: new Adapter() });
fetchMock.enableMocks();

View File

@@ -1,23 +1,8 @@
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 = {
// called when the user attempts to log in
login: ({ homeserver, username, password }) => {
login: ({ base_url, username, password }) => {
console.log("login ");
const options = {
method: "POST",
@@ -28,17 +13,16 @@ const authProvider = {
}),
};
const url = window.decodeURIComponent(homeserver);
const trimmed_url = url.trim().replace(/\s/g, "");
const login_api_url =
ensureHttpsForUrl(trimmed_url) + "/_matrix/client/r0/login";
// use the base_url from login instead of the well_known entry from the
// server, since the admin might want to access the admin API via some
// private address
localStorage.setItem("base_url", base_url);
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 }) => {
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("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token);
localStorage.setItem("device_id", json.device_id);

View File

@@ -14,20 +14,40 @@ const jsonClient = (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 = {
users: {
path: "/_synapse/admin/v2/users",
map: u => ({
...u,
id: u.name,
avatar_src: mxcUrlToHttp(u.avatar_url),
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
displayname: u.display_name || u.displayname,
// need timestamp in milliseconds
creation_ts_ms: u.creation_ts * 1000,
}),
data: "users",
total: json => json.total,
delete: id => ({
endpoint: `/_synapse/admin/v1/deactivate/${id}`,
create: data => ({
endpoint: `/_synapse/admin/v2/users/${data.id}`,
body: data,
method: "PUT",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/deactivate/${params.id}`,
body: { erase: true },
method: "POST",
}),
@@ -37,18 +57,50 @@ const resourceMap = {
map: r => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
is_encrypted: !!r.encryption,
federatable: !!r.federatable,
public: !!r.public,
}),
data: "rooms",
total: json => json.total_rooms,
create: params => ({
method: "POST",
create: data => ({
endpoint: "/_matrix/client/r0/createRoom",
body: {
name: params.data.name,
room_alias_name: params.data.canonical_alias,
visibility: params.data.public ? "public" : "private",
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,
},
map: r => ({ id: r.room_id }),
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: {
@@ -59,6 +111,29 @@ const resourceMap = {
}),
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) {
@@ -69,11 +144,20 @@ function filterNullValues(key, value) {
return value;
}
function getSearchOrder(order) {
if (order === "DESC") {
return "b";
} else {
return "f";
}
}
const dataProvider = {
getList: (resource, params) => {
console.log("getList " + resource);
const { user_id, guests, deactivated } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
const query = {
from: from,
@@ -81,6 +165,8 @@ const dataProvider = {
user_id: user_id,
guests: guests,
deactivated: deactivated,
order_by: field,
dir: getSearchOrder(order),
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@@ -125,30 +211,18 @@ const dataProvider = {
},
getManyReference: (resource, params) => {
// FIXME
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");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const ref = res["reference"](params.id);
const endpoint_url = homeserver + ref.endpoint;
return jsonClient(url).then(({ headers, json }) => ({
data: json,
total: parseInt(headers.get("content-range").split("/").pop(), 10),
return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map),
}));
},
@@ -192,25 +266,39 @@ const dataProvider = {
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
if (!("create" in res)) return Promise.reject();
if ("create" in res) {
const create = res["create"](params);
const endpoint_url = homeserver + create.endpoint;
return jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
}).then(({ json }) => ({
data: create.map(json),
}));
} else {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.data.id}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: res.map(json),
}));
}
const create = res["create"](params.data);
const endpoint_url = homeserver + create.endpoint;
return jsonClient(endpoint_url, {
method: create.method,
body: JSON.stringify(create.body, filterNullValues),
}).then(({ 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) => {
@@ -221,11 +309,11 @@ const dataProvider = {
const res = resourceMap[resource];
if ("delete" in res) {
const del = res["delete"](params.id);
const del = res["delete"](params);
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: del.method,
body: JSON.stringify(del.body),
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
}).then(({ json }) => ({
data: json,
}));
@@ -250,11 +338,11 @@ const dataProvider = {
if ("delete" in res) {
return Promise.all(
params.ids.map(id => {
const del = res["delete"](id);
const del = res["delete"]({ ...params, id: id });
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: del.method,
body: JSON.stringify(del.body),
method: "method" in del ? del.method : "DELETE",
body: "body" in del ? JSON.stringify(del.body) : null,
});
})
).then(responses => ({

View File

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

3256
yarn.lock

File diff suppressed because it is too large Load Diff