40 Commits

Author SHA1 Message Date
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
76fdc80e3e Merge branch 'master' into amp.chat
Change-Id: I08a7a34e041993c29bb12fff52d07534374cda4e
2020-04-28 16:35:40 +02:00
Manuel Stahl
375649756f Add page to show room details
Change-Id: Iec4f402c4322d775cc14c567069a3295ad383b44
2020-04-28 16:30:47 +02:00
Manuel Stahl
662735a91f Adapt for changes in v2/users API
Change-Id: I927b81882fa20e5b3de3d9fc216e2136f7036bba
2020-04-28 16:30:47 +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
Manuel Stahl
1e6e526e3c Show displayname as title for user
Change-Id: I0ba8e2265e5b8e1fe392f56052e96e0243cd3eb6
2020-04-23 16:50:58 +02:00
Manuel Stahl
0823976edd Cleanup room creation
Change-Id: Ieb5189513d21606f8d0bea5692112350a68f2e14
2020-04-23 16:31:26 +02:00
Manuel Stahl
d812cff5fc Rename homeserver_url to endpoint_url in dataProvider
Change-Id: I86441cd90e9b9b6b04ba2d28fee12a1cfa0684a7
2020-04-23 14:29:13 +02:00
Manuel Stahl
a39033e25b yarn: Upgrade packages
Change-Id: Id01070e1e4ca2c2dd42c8ffcd278a57cb31b7b51
2020-04-23 14:29:00 +02:00
Michael Albert
d3cd2e9e33 Fix localStorage entry of homeserver url
Change-Id: I206e3b4428df1f51d4281ad4db26bd64bdffb85d
2020-04-21 17:42:43 +02:00
Timo Paulssen
24abcd4e4a Normalize alias a little, display initial sigil
turns all whitespace into underscores, shows leading
sigil if the alias is non-empty, so the user doesn't get
confused about whether they have to input a # or not.

Change-Id: Ic81e69cc3f0074d63a67b976c9bda32f8de025de
2020-04-20 19:31:33 +02:00
Timo Paulssen
c1c32e3268 Offer "alias" field in room create form
Tries its best to not allow aliases that are too
long (full alias including leading #, : in the
middle, and homeserver domain name must not exceed
255 bytes.

Change-Id: I1e784a94cb599eca7e30736d666b20e37aad5444
2020-04-20 19:31:33 +02:00
Timo Paulssen
ca15435625 Offer room creation form
A choice of public or private is offered, which maps to matrix'
visibility parameter. A name can also be provided.

Change-Id: I34d99acbc4624a9ed54ca6f6609573d5fc1049da
2020-04-20 19:31:33 +02:00
Michael Albert
e9c3901b68 Merge branch 'master' into amp.chat
Change-Id: Iac4e56401aab3f7f39b623b617990ec1952f8cd0
2020-04-20 16:57:23 +02:00
Michael Albert
7aec6f9369 Allow searching for users
Change-Id: Icf4a3b05b24c66971f55b22e7540a1dc904a3a92
2020-04-20 11:22:06 +00:00
Michael Albert
d2a3f07a59 Fix QR code creation
Change-Id: Ib6bbd1be6d4dca1f617043c3c2338924b2321ea3
2020-04-20 12:15:52 +02:00
Manuel Stahl
b7f009e559 Add buttons to erase users (#32)
Change-Id: I9b5644394d213dc66b30e39f19e9e392b69a0be3
2020-04-09 11:37:02 +02:00
Manuel Stahl
2240559f74 yarn: Upgrade packages
- react: 16.13.1
- react-admin: 3.4.0
- prettier: 2.0.0

Change-Id: Ibbd4ff2bd8054a7086d194229ec732c1a256579c
2020-04-06 12:17:42 +02:00
dklimpel
445c7fc327 Add more size options to pagination (#23) 2020-04-06 11:41:15 +02:00
Manuel Stahl
965645874c Fix coding style 2020-04-06 11:32:28 +02:00
Manuel Stahl
185d71e5fc Add helper text to deactivate switch of users (#31) 2020-04-06 11:13:20 +02:00
dklimpel
7ef6bc05c6 Add list of client connections per user (#26)
List the result of GET /_synapse/admin/v1/whois/<user_id>.
2020-03-30 10:42:31 +02:00
dklimpel
dfc643a10f Add 3pids to user (#24) 2020-03-30 10:23:36 +02:00
Manuel Stahl
fccf23c64c yarn: Upgrade packages
Change-Id: I46dc0b712ef22062fd5449d1498adbcf4a447176
2020-03-27 21:24:01 +01:00
Manuel Stahl
e4dd013c19 Fix coding style
Change-Id: Ifa944a7eb541b290f54d67eb654b893e71724843
2020-03-27 21:22:16 +01:00
Manuel Stahl
7eeb60539f Merge pull request #22 from rkfg/bugfix
Fix pagination and use deactivated users switch
2020-03-26 14:24:21 +01:00
rkfg
d099e582e0 Fix pagination and use deactivated users switch 2020-03-24 18:59:48 +03:00
Manuel Stahl
0a241539f2 Merge pull request #19 from dependabot/npm_and_yarn/acorn-5.7.4
Bump acorn from 5.7.3 to 5.7.4
2020-03-16 10:43:57 +01:00
dependabot[bot]
316d674060 Bump acorn from 5.7.3 to 5.7.4
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-14 02:44:46 +00:00
teutates
40b5031550 Use proper homeserver URL for API requests 2020-03-05 10:04:22 +01:00
Manuel Stahl
475aa11f06 Use prettier also for *.js and *.jsx files
Change-Id: I46190f70ce05148c565222d53bc9ae3e862464bf
2020-02-19 16:29:21 +01:00
14 changed files with 2084 additions and 1216 deletions

View File

@@ -6,5 +6,6 @@
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
"jsxBracketSameLine": false,
"arrowParens": "avoid",
}

View File

@@ -4,20 +4,21 @@
"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"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^9.4.0",
"@testing-library/user-event": "^8.1.0",
"@testing-library/react": "^10.0.2",
"@testing-library/user-event": "^10.0.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-prettier": "^3.1.2",
"prettier": "^1.19.1"
"prettier": "^2.0.0"
},
"dependencies": {
"@progress/kendo-drawing": "^1.6.0",
@@ -25,10 +26,10 @@
"prop-types": "^15.7.2",
"qrcode.react": "^1.0.0",
"ra-language-german": "^2.1.2",
"react": "^16.12.0",
"react-admin": "^3.1.3",
"react-dom": "^16.12.0",
"react-scripts": "^3.3.0"
"react": "^16.13.1",
"react-admin": "^3.4.0",
"react-dom": "^16.13.1",
"react-scripts": "^3.4.1"
},
"scripts": {
"start": "react-scripts start",
@@ -36,7 +37,7 @@
"fix:other": "yarn prettier --write",
"fix:code": "yarn test:lint --fix",
"fix": "yarn fix:code && yarn fix:other",
"prettier": "prettier \"**/*.{json,md,scss,yaml,yml}\"",
"prettier": "prettier \"**/*.{js,jsx,json,md,scss,yaml,yml}\"",
"test:code": "react-scripts test",
"test:lint": "eslint --ignore-path .gitignore --ext .js,.jsx .",
"test:style": "yarn prettier --list-different",

View File

@@ -4,7 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList } from "./components/rooms";
import { RoomList, RoomCreate, RoomShow } from "./components/rooms";
import LoginPage from "./components/LoginPage";
import UserIcon from "@material-ui/icons/Group";
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
@@ -40,7 +40,15 @@ const App = () => (
edit={UserEdit}
icon={UserIcon}
/>
<Resource name="rooms" list={RoomList} icon={RoomIcon} />
<Resource
name="rooms"
list={RoomList}
create={RoomCreate}
show={RoomShow}
icon={RoomIcon}
/>
<Resource name="connections" />
<Resource name="servernotices" />
</Admin>
);

View File

@@ -1,13 +1,17 @@
import React, { useState } 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,
@@ -34,7 +38,7 @@ const useStyles = makeStyles(theme => ({
backgroundSize: "cover",
},
card: {
minWidth: 300,
minWidth: "30em",
marginTop: "6em",
},
avatar: {
@@ -70,7 +74,7 @@ const LoginPage = ({ theme }) => {
var locale = useLocale();
const setLocale = useSetLocale();
const translate = useTranslate();
const homeserver = localStorage.getItem("home_server");
const base_url = localStorage.getItem("base_url");
const renderInput = ({
meta: { touched, error } = {},
@@ -88,15 +92,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 +127,75 @@ 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 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}`);
});
}
};
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>
);
};
return (
<Form
initialValues={{ homeserver: homeserver }}
initialValues={{ base_url: base_url }}
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
@@ -146,32 +224,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,35 @@
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;

View File

@@ -0,0 +1,100 @@
import React, { Fragment, useState } from "react";
import {
Button,
SaveButton,
SimpleForm,
TextInput,
Toolbar,
required,
useCreate,
useNotify,
useTranslate,
} 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>
);
};

View File

@@ -47,7 +47,6 @@ const ShowUserPdf = props => {
marginTop: 15,
},
logo: {
height: 90,
width: 90,
marginTop: 20,
marginRight: 32,

View File

@@ -1,9 +1,32 @@
import React from "react";
import { Datagrid, List, TextField } from "react-admin";
import {
AutocompleteArrayInput,
BooleanInput,
Create,
Datagrid,
FormTab,
List,
Pagination,
ReferenceArrayField,
ReferenceArrayInput,
Show,
Tab,
TabbedForm,
TabbedShowLayout,
TextField,
TextInput,
useTranslate,
} from "react-admin";
import ViewListIcon from "@material-ui/icons/ViewList";
import UserIcon from "@material-ui/icons/Group";
const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const RoomList = props => (
<List {...props}>
<Datagrid>
<List {...props} pagination={<RoomPagination />}>
<Datagrid rowClick="show">
<TextField source="room_id" />
<TextField source="name" />
<TextField source="canonical_alias" />
@@ -11,3 +34,134 @@ export const RoomList = props => (
</Datagrid>
</List>
);
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" />
</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 => (
<Show {...props} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.details" icon={<ViewListIcon />}>
<TextField source="id" disabled />
<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>
);

View File

@@ -1,25 +1,46 @@
import React from "react";
import React, { Fragment } from "react";
import PersonPinIcon from "@material-ui/icons/PersonPin";
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
import {
ArrayInput,
ArrayField,
Datagrid,
DateField,
Create,
Edit,
List,
Filter,
Toolbar,
SimpleForm,
SimpleFormIterator,
TabbedForm,
FormTab,
BooleanField,
BooleanInput,
ImageField,
PasswordInput,
TextField,
TextInput,
SearchInput,
ReferenceField,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
Toolbar,
regex,
useTranslate,
Pagination,
} from "react-admin";
import SaveQrButton from "./SaveQrButton";
import { ServerNoticeButton } from "./ServerNotices";
const UserPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserFilter = props => (
<Filter {...props}>
<SearchInput source="user_id" alwaysOn />
<BooleanInput source="guests" alwaysOn />
<BooleanInput
label="resources.users.fields.show_deactivated"
@@ -29,32 +50,31 @@ const UserFilter = props => (
</Filter>
);
const UserBulkActionButtons = props => {
const translate = useTranslate();
return (
<Fragment>
<BulkDeleteButton
{...props}
label="resources.users.action.erase"
title={translate("resources.users.helper.erase")}
/>
</Fragment>
);
};
export const UserList = props => (
<List
{...props}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
bulkActionButtons={false}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit">
<ReferenceField
source="Avatar"
reference="users"
link={false}
sortable={false}
>
<ImageField source="avatar_url" title="displayname" />
</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" />
</ReferenceField>
<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} />
@@ -117,7 +137,7 @@ const redirect = (basePath, id, data) => {
const UserCreateToolbar = props => (
<Toolbar {...props}>
<SaveButton
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirect}
submitOnEnter={true}
@@ -137,6 +157,30 @@ const validateUser = regex(
"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 => (
<Create record={generateRandomUser()} {...props}>
<SimpleForm toolbar={<UserCreateToolbar />}>
@@ -144,18 +188,89 @@ export const UserCreate = props => (
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
<BooleanInput source="admin" />
<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>
</SimpleForm>
</Create>
);
const UserTitle = ({ record }) => {
const translate = useTranslate();
return (
<span>
{translate("resources.users.name")}{" "}
{record ? `"${record.displayname}"` : ""}
</span>
);
};
export const UserEdit = props => (
<Edit {...props}>
<SimpleForm toolbar={<UserCreateToolbar />}>
<TextInput source="id" disabled />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
<BooleanInput source="admin" />
<BooleanInput source="deactivated" />
</SimpleForm>
<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"
>
<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,22 +4,38 @@ export default {
...germanMessages,
synapseadmin: {
auth: {
homeserver: "Heimserver",
base_url: "Heimserver URL",
welcome: "Willkommen bei Synapse-admin",
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: "QR Code erzeugen",
save_only: "Speichern",
save_and_show: "Speichern und QR Code erzeugen",
save_only: "Nur speichern",
download_pdf: "PDF speichern",
},
users: {
invalid_user_id:
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
},
rooms: {
details: "Raumdetails",
room_name: "Raumname",
make_public: "Öffentlicher 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",
},
},
resources: {
users: {
backtolist: "Zurück zur Liste",
name: "Benutzer",
email: "E-Mail",
msisdn: "Telefon",
fields: {
avatar: "Avatar",
id: "Benutzer-ID",
@@ -32,6 +48,17 @@ export default {
user_id: "Suche Benutzer",
displayname: "Anzeigename",
password: "Passwort",
avatar_url: "Avatar URL",
medium: "Medium",
threepids: "3PIDs",
address: "Adresse",
},
helper: {
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
},
action: {
erase: "Lösche Benutzerdaten",
},
},
rooms: {
@@ -41,6 +68,62 @@ export default {
name: "Name",
canonical_alias: "Alias",
joined_members: "Mitglieder",
invite_members: "Mitglieder einladen",
invitees: "Einladungen",
},
},
connections: {
name: "Verbindungen",
fields: {
last_seen: "Datum",
ip: "IP-Adresse",
user_agent: "User Agent",
},
},
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",
},
},
ra: {
...germanMessages.ra,
input: {
...germanMessages.ra.input,
password: {
...germanMessages.ra.input.password,
toggle_hidden: "Anzeigen",
toggle_visible: "Verstecken",
},
},
},

View File

@@ -4,8 +4,11 @@ export default {
...englishMessages,
synapseadmin: {
auth: {
homeserver: "Homeserver",
base_url: "Homeserver URL",
welcome: "Welcome to Synapse-admin",
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",
@@ -16,10 +19,23 @@ export default {
invalid_user_id:
"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",
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.",
},
},
resources: {
users: {
backtolist: "Back to list",
name: "User |||| Users",
email: "Email",
msisdn: "Phone",
fields: {
avatar: "Avatar",
id: "User-ID",
@@ -32,6 +48,17 @@ export default {
user_id: "Search user",
displayname: "Displayname",
password: "Password",
avatar_url: "Avatar URL",
medium: "Medium",
threepids: "3PIDs",
address: "Address",
},
helper: {
deactivate: "Deactivated users cannot be reactivated",
erase: "Mark the user as GDPR-erased",
},
action: {
erase: "Erase user data",
},
},
rooms: {
@@ -41,6 +68,33 @@ export default {
name: "Name",
canonical_alias: "Alias",
joined_members: "Members",
invite_members: "Invite Members",
invitees: "Invitations",
},
},
connections: {
name: "Connections",
fields: {
last_seen: "Date",
ip: "IP address",
user_agent: "User agent",
},
},
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

@@ -2,7 +2,7 @@ import { fetchUtils } from "react-admin";
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",
@@ -13,15 +13,15 @@ const authProvider = {
}),
};
// add 'https://' to homeserver url if its missing
let newUrl = window.decodeURIComponent(homeserver);
newUrl = newUrl.trim().replace(/\s/g, "");
if (!/^https?:\/\//i.test(newUrl)) {
homeserver = `https://${newUrl}`;
}
// 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 url = homeserver + "/_matrix/client/r0/login";
return fetchUtils.fetchJson(url, options).then(({ json }) => {
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 }) => {
localStorage.setItem("home_server", json.home_server);
localStorage.setItem("user_id", json.user_id);
localStorage.setItem("access_token", json.access_token);

View File

@@ -23,24 +23,64 @@ const resourceMap = {
is_guest: !!u.is_guest,
admin: !!u.admin,
deactivated: !!u.deactivated,
displayname: u.display_name || u.displayname,
}),
data: "users",
total: (json, perPage) => {
return parseInt(json.next_token, 10) + perPage;
},
total: json => json.total,
create: data => ({
endpoint: `/_synapse/admin/v2/users/${data.id}`,
body: data,
method: "PUT",
}),
delete: id => ({
endpoint: `/_synapse/admin/v1/deactivate/${id}`,
body: { erase: true },
method: "POST",
}),
},
rooms: {
path: "/_synapse/admin/v1/rooms",
map: r => ({
...r,
id: r.room_id,
alias: r.canonical_alias,
members: r.joined_members,
}),
data: "rooms",
total: json => {
return json.total_rooms;
},
total: json => json.total_rooms,
create: data => ({
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,
},
method: "POST",
})
},
connections: {
path: "/_synapse/admin/v1/whois",
map: c => ({
...c,
id: c.user_id,
}),
data: "connections",
},
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",
}),
},
};
@@ -55,48 +95,53 @@ function filterNullValues(key, value) {
const dataProvider = {
getList: (resource, params) => {
console.log("getList " + resource);
const { user_id, guests } = params.filter;
const { user_id, guests, deactivated } = params.filter;
const { page, perPage } = params.pagination;
const from = (page - 1) * perPage;
const query = {
from: (page - 1) * perPage,
from: from,
limit: perPage,
user_id: user_id,
guests: guests,
deactivated: deactivated,
};
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
const url = `${homeserver_url}?${stringify(query)}`;
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
return jsonClient(url).then(({ json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, perPage),
total: res.total(json, from, perPage),
}));
},
getOne: (resource, params) => {
console.log("getOne " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
return jsonClient(`${homeserver_url}/${params.id}`).then(({ json }) => ({
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`).then(({ json }) => ({
data: res.map(json),
}));
},
getMany: (resource, params) => {
console.log("getMany " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id => jsonClient(`${homeserver_url}/${id}`))
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`))
).then(responses => ({
data: responses.map(({ json }) => res.map(json)),
}));
@@ -116,33 +161,29 @@ const dataProvider = {
}),
};
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
const url = `${homeserver_url}?${stringify(query)}`;
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
return jsonClient(url).then(({ headers, json }) => ({
data: json,
total: parseInt(
headers
.get("content-range")
.split("/")
.pop(),
10
),
total: parseInt(headers.get("content-range").split("/").pop(), 10),
}));
},
update: (resource, params) => {
console.log("update " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
return jsonClient(`${homeserver_url}/${params.data.id}`, {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.data.id}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
@@ -152,13 +193,14 @@ const dataProvider = {
updateMany: (resource, params) => {
console.log("updateMany " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id => jsonClient(`${homeserver_url}/${id}`), {
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`), {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
})
@@ -169,14 +211,17 @@ const dataProvider = {
create: (resource, params) => {
console.log("create " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
return jsonClient(`${homeserver_url}/${params.data.id}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
if (!("create" in res)) return Promise.reject();
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),
}));
@@ -184,35 +229,64 @@ const dataProvider = {
delete: (resource, params) => {
console.log("delete " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
return jsonClient(`${homeserver_url}/${params.id}`, {
method: "DELETE",
}).then(({ json }) => ({
data: json,
}));
if ("delete" in res) {
const del = res["delete"](params.id);
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: del.method,
body: JSON.stringify(del.body),
}).then(({ json }) => ({
data: json,
}));
} else {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: json,
}));
}
},
deleteMany: (resource, params) => {
console.log("deleteMany " + resource);
const homeserver = localStorage.getItem("home_server");
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
const res = resourceMap[resource];
const homeserver_url = "https://" + homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${homeserver_url}/${id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
}).then(responses => ({
data: responses.map(({ json }) => json),
}))
)
);
if ("delete" in res) {
return Promise.all(
params.ids.map(id => {
const del = res["delete"](id);
const endpoint_url = homeserver + del.endpoint;
return jsonClient(endpoint_url, {
method: del.method,
body: JSON.stringify(del.body),
});
})
).then(responses => ({
data: responses.map(({ json }) => json),
}));
} else {
const endpoint_url = homeserver + res.path;
return Promise.all(
params.ids.map(id =>
jsonClient(`${endpoint_url}/${id}`, {
method: "DELETE",
body: JSON.stringify(params.data, filterNullValues),
})
)
).then(responses => ({
data: responses.map(({ json }) => json),
}));
}
},
};

2317
yarn.lock

File diff suppressed because it is too large Load Diff