25 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
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
Manuel Stahl
dd00a76603 Merge tag '0.4.2' into amp.chat
Change-Id: Id12309f0a4d3ff9983325e69131d5eebe5bd0bde
2020-07-30 12:56:20 +02:00
Manuel Stahl
2915fd3e5b Merge tag '0.4.1' into amp.chat
Change-Id: I44c9f00e5aa7abe413f8a819e1143bebc4f08ce2
2020-07-28 15:09:48 +02:00
Michael Albert
a4662c2557 Translate room info
Change-Id: I7f3121da3c910592ecfcb4bca9dee34f2757f567
2020-06-10 07:18:58 +00:00
Michael Albert
f6ca169fbc Fix data provider
Change-Id: Id1c929f593833ed35327e70d1d0dc8182a4b7306
2020-05-25 21:20:49 +02:00
Michael Albert
07862591fd Possibility to encrypt new rooms
Change-Id: Ie415a0f8ecec646510ac8f2f0adca58064e30da5
2020-05-25 13:25:46 +02:00
Manuel Stahl
ab649fbf70 Merge branch 'master' into amp.chat
Change-Id: I6141964157bcb7218e2e6368a3ca8d20eb4855e9
2020-05-05 13:44:06 +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
0823976edd Cleanup room creation
Change-Id: Ieb5189513d21606f8d0bea5692112350a68f2e14
2020-04-23 16:31:26 +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
bf7867f106 Merge branch 'master' into amp.chat
Change-Id: I45b7a6db27456aaa2eca66b406cdaa49e492e61e
2020-02-11 18:56:53 +01:00
Michael Albert
f0e32abc4f Fix QR code creation
Change-Id: If05856a6fdafa43a93c6b57963820710db188d42
2020-02-11 17:35:19 +00:00
Michael Albert
61b1580735 Fix redirect after create/edit user
Change-Id: Icdb797bf6b1a47cbeff901b1952672584b2e8e8f
2020-02-11 17:34:32 +00:00
Manuel Stahl
0f7e4c1909 Create PDF with QR code on user create/edit
Change-Id: Ib89b68e956d96002ddbf6ac5ddcaea73b5b3e3fb
2020-02-10 13:10:08 +01:00
Michael Albert
c9bce409d2 Prefill user_id and password on user creation
Change-Id: I3f604f38c1842f155f3b39da20ba45992ba522be
2020-02-10 13:10:08 +01:00
23 changed files with 1635 additions and 2614 deletions

View File

@@ -4,19 +4,7 @@
This project is built using [react-admin](https://marmelab.com/react-admin/).
It needs at least Synapse v1.23.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://github.com/matrix-org/synapse/blob/develop/docs/admin_api/version_api.rst).
After entering the URL on the login page of synapse-admin the server version appears below the input field.
You need access to the following endpoints:
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://github.com/matrix-org/synapse/blob/develop/docs/reverse_proxy.md#synapse-administration-endpoints)
It needs at least Synapse v1.18.0 for all functions to work as expected!
## Step-By-Step install:

View File

@@ -1,6 +1,6 @@
{
"name": "synapse-admin",
"version": "0.7.0",
"version": "AMP/2020.08",
"description": "Admin GUI for the Matrix.org server Synapse",
"author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0",
@@ -22,13 +22,16 @@
"prettier": "^2.0.0"
},
"dependencies": {
"papaparse": "^5.2.0",
"@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.10.0",
"react-dom": "^16.14.0",
"react-scripts": "^3.4.4"
"react-admin": "^3.7.0",
"react-dom": "^16.13.1",
"react-scripts": "^3.4.1"
},
"scripts": {
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",

View File

@@ -1,3 +0,0 @@
id,displayname,password,is_guest,admin,deactivated
@testuser22:example.org,Jane Doe,secretpassword,false,true,false
,John Doe,,false,false,false
1 id displayname password is_guest admin deactivated
2 @testuser22:example.org Jane Doe secretpassword false true false
3 John Doe false false false

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

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/
@@ -46,4 +72,4 @@
</a>
</footer>
</body>
</html>
</html>

View File

@@ -4,18 +4,14 @@ 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, RoomShow } from "./components/rooms";
import { ReportList, ReportShow } from "./components/EventReports";
import { RoomList, RoomCreate, RoomShow } from "./components/rooms";
import LoginPage from "./components/LoginPage";
import UserIcon from "@material-ui/icons/Group";
import EqualizerIcon from "@material-ui/icons/Equalizer";
import { UserMediaStatsList } from "./components/statistics";
import RoomIcon from "@material-ui/icons/ViewList";
import ReportIcon from "@material-ui/icons/Warning";
import { ImportFeature } from "./components/ImportFeature";
import { Route } from "react-router-dom";
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import ShowUserPdf from "./components/ShowUserPdf";
import { Route } from "react-router-dom";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
@@ -29,13 +25,12 @@ const i18nProvider = polyglotI18nProvider(
const App = () => (
<Admin
disableTelemetry
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
customRoutes={[
<Route key="userImport" path="/import_users" component={ImportFeature} />,
<Route key="showpdf" path="/showpdf" component={ShowUserPdf} />,
]}
>
<Resource
@@ -45,24 +40,16 @@ const App = () => (
edit={UserEdit}
icon={UserIcon}
/>
<Resource name="rooms" list={RoomList} show={RoomShow} icon={RoomIcon} />
<Resource
name="user_media_statistics"
list={UserMediaStatsList}
icon={EqualizerIcon}
/>
<Resource
name="reports"
list={ReportList}
show={ReportShow}
icon={ReportIcon}
name="rooms"
list={RoomList}
create={RoomCreate}
show={RoomShow}
icon={RoomIcon}
/>
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
</Admin>
);

View File

@@ -1,135 +0,0 @@
import React from "react";
import {
Datagrid,
DateField,
List,
NumberField,
Pagination,
ReferenceField,
Show,
Tab,
TabbedShowLayout,
TextField,
useTranslate,
} from "react-admin";
import PageviewIcon from "@material-ui/icons/Pageview";
import ViewListIcon from "@material-ui/icons/ViewList";
const ReportPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = props => {
const translate = useTranslate();
return (
<Show {...props}>
<TabbedShowLayout>
<Tab
label={translate("synapseadmin.reports.tabs.basic", {
smart_count: 1,
})}
icon={<ViewListIcon />}
>
<DateField
source="received_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<ReferenceField source="user_id" reference="users">
<TextField source="id" />
</ReferenceField>
<NumberField source="score" />
<TextField source="reason" />
<TextField source="name" />
<TextField
source="canonical_alias"
label="resources.rooms.fields.canonical_alias"
/>
<ReferenceField
source="room_id"
reference="rooms"
link="show"
label="resources.rooms.fields.room_id"
>
<TextField source="id" />
</ReferenceField>
</Tab>
<Tab
label="synapseadmin.reports.tabs.detail"
icon={<PageviewIcon />}
path="detail"
>
{" "}
<DateField
source="event_json.origin_server_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<ReferenceField source="sender" reference="users">
<TextField source="id" />
</ReferenceField>
<TextField source="event_id" />
<TextField source="event_json.origin" />
<TextField source="event_json.type" />
<TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" />
<TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" />
<TextField
source="event_json.content.device_id"
label="resources.users.fields.device_id"
/>
</Tab>
</TabbedShowLayout>
</Show>
);
};
export const ReportList = ({ ...props }) => {
return (
<List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick="show">
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={true}
/>
<TextField sortable={false} source="user_id" />
<TextField sortable={false} source="name" />
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
};

View File

@@ -1,674 +0,0 @@
import React, { useState } from "react";
import {
Button as ReactAdminButton,
useDataProvider,
useNotify,
Title,
} from "react-admin";
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
import GetAppIcon from "@material-ui/icons/GetApp";
import {
Button,
Card,
CardActions,
CardContent,
CardHeader,
FormControlLabel,
Checkbox,
NativeSelect,
} from "@material-ui/core";
import { useTranslate } from "ra-core";
import Container from "@material-ui/core/Container/Container";
import { generateRandomUser } from "./users";
const LOGGING = true;
export const ImportButton = ({ label, variant = "text" }) => {
return (
<ReactAdminButton
color="primary"
component="span"
variant={variant}
label={label}
>
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</ReactAdminButton>
);
};
const expectedFields = ["id", "displayname"].sort();
const optionalFields = [
"user_type",
"guest",
"admin",
"deactivated",
"avatar_url",
"password",
].sort();
function TranslatableOption({ value, text }) {
const translate = useTranslate();
return <option value={value}>{translate(text)}</option>;
}
const FilePicker = props => {
const [values, setValues] = useState(null);
const [error, setError] = useState(null);
const [stats, setStats] = useState(null);
const [dryRun, setDryRun] = useState(true);
const [progress, setProgress] = useState(null);
const [importResults, setImportResults] = useState(null);
const [skippedRecords, setSkippedRecords] = useState(null);
const [conflictMode, setConflictMode] = useState("stop");
const [passwordMode, setPasswordMode] = useState(true);
const [useridMode, setUseridMode] = useState("ignore");
const translate = useTranslate();
const notify = useNotify();
const dataProvider = useDataProvider();
const onFileChange = async e => {
if (progress !== null) return;
setValues(null);
setError(null);
setStats(null);
setImportResults(null);
const file = e.target.files ? e.target.files[0] : null;
/* Let's refuse some unreasonably big files instead of freezing
* up the browser */
if (file.size > 100000000) {
const message = translate("import_users.errors.unreasonably_big", {
size: (file.size / (1024 * 1024)).toFixed(2),
});
notify(message);
setError(message);
return;
}
try {
parseCsv(file, {
header: true,
skipEmptyLines: true /* especially for a final EOL in the csv file */,
complete: result => {
if (result.error) {
setError(result.error);
}
/* Papaparse is very lenient, we may be able to salvage
* the data in the file. */
verifyCsv(result, { setValues, setStats, setError });
},
});
} catch {
setError(true);
return null;
}
};
const verifyCsv = (
{ data, meta, errors },
{ setValues, setStats, setError }
) => {
/* First, verify the presence of required fields */
let eF = Array.from(expectedFields);
let oF = Array.from(optionalFields);
meta.fields.forEach(name => {
if (eF.includes(name)) {
eF = eF.filter(v => v !== name);
}
if (oF.includes(name)) {
oF = oF.filter(v => v !== name);
}
});
if (eF.length !== 0) {
setError(
translate("import_users.error.required_field", { field: eF[0] })
);
return false;
}
// XXX after deciding on how "name" and friends should be handled below,
// this place will want changes, too.
/* Collect some stats to prevent sneaky csv files from adding admin
users or something.
*/
let stats = {
user_types: { default: 0 },
is_guest: 0,
admin: 0,
deactivated: 0,
password: 0,
avatar_url: 0,
id: 0,
total: data.length,
};
data.forEach((line, idx) => {
if (line.user_type === undefined || line.user_type === "") {
stats.user_types.default++;
} else {
stats.user_types[line.user_type] += 1;
}
/* XXX correct the csv export that react-admin offers for the users
* resource so it gives sensible field names and doesn't duplicate
* id as "name"?
*/
if (meta.fields.includes("name")) {
delete line.name;
}
if (meta.fields.includes("user_type")) {
delete line.user_type;
}
if (meta.fields.includes("is_admin")) {
line.admin = line.is_admin;
delete line.is_admin;
}
["is_guest", "admin", "deactivated"].forEach(f => {
if (line[f] === "true") {
stats[f]++;
line[f] = true; // we need true booleans instead of strings
} else {
if (line[f] !== "false" && line[f] !== "") {
errors.push(
translate("import_users.error.invalid_value", {
field: f,
row: idx,
})
);
}
line[f] = false; // default values to false
}
});
if (line.password !== undefined && line.password !== "") {
stats.password++;
}
if (line.avatar_url !== undefined && line.avatar_url !== "") {
stats.avatar_url++;
}
if (line.id !== undefined && line.id !== "") {
stats.id++;
}
});
if (errors.length > 0) {
setError(errors);
}
setStats(stats);
setValues(data);
return true;
};
const runImport = async e => {
if (progress !== null) {
notify("import_users.errors.already_in_progress");
return;
}
const results = await doImport(
dataProvider,
values,
conflictMode,
passwordMode,
useridMode,
dryRun,
setProgress,
setError
);
setImportResults(results);
// offer CSV download of skipped or errored records
// (so that the user doesn't have to filter out successful
// records manually when fixing stuff in the CSV)
setSkippedRecords(unparseCsv(results.skippedRecords));
if (LOGGING) console.log("Skipped records:");
if (LOGGING) console.log(skippedRecords);
};
// XXX every single one of the requests will restart the activity indicator
// which doesn't look very good.
const doImport = async (
dataProvider,
data,
conflictMode,
passwordMode,
useridMode,
dryRun,
setProgress,
setError
) => {
let skippedRecords = [];
let erroredRecords = [];
let succeededRecords = [];
let changeStats = {
toAdmin: 0,
toGuest: 0,
toRegular: 0,
replacedPassword: 0,
};
let entriesDone = 0;
let entriesCount = data.length;
try {
setProgress({ done: entriesDone, limit: entriesCount });
for (const entry of data) {
let userRecord = {};
let overwriteData = {};
// No need to do a bunch of cryptographic random number getting if
// we are using neither a generated password nor a generated user id.
if (
useridMode === "ignore" ||
entry.id === undefined ||
entry.password === undefined ||
passwordMode === false
) {
overwriteData = generateRandomUser();
// Ignoring IDs or the entry lacking an ID means we keep the
// ID field in the overwrite data.
if (!(useridMode === "ignore" || entry.id === undefined)) {
delete overwriteData.id;
}
// Not using passwords from the csv or this entry lacking a password
// means we keep the password field in the overwrite data.
if (
!(
passwordMode === false ||
entry.password === undefined ||
entry.password === ""
)
) {
delete overwriteData.password;
}
}
/* TODO record update stats (especially admin no -> yes, deactivated x -> !x, ... */
Object.assign(userRecord, entry);
Object.assign(userRecord, overwriteData);
/* For these modes we will consider the ID that's in the record.
* If the mode is "stop", we will not continue adding more records, and
* we will offer information on what was already added and what was
* skipped.
*
* If the mode is "skip", we record the record for later, but don't
* send it to the server.
*
* If the mode is "update", we change fields that are reasonable to
* update.
* - If the "password mode" is "true" (i.e. "use passwords from csv"):
* - if the record has a password
* - send the password along with the record
* - if the record has no password
* - generate a new password
* - If the "password mode" is "false"
* - never generate a new password to update existing users with
*/
/* We just act as if there are no IDs in the CSV, so every user will be
* created anew.
* We do a simple retry loop so that an accidental hit on an existing ID
* doesn't trip us up.
*/
if (LOGGING)
console.log(
"will check for existence of record " + JSON.stringify(userRecord)
);
let retries = 0;
const submitRecord = recordData => {
return dataProvider.getOne("users", { id: recordData.id }).then(
async alreadyExists => {
if (LOGGING) console.log("already existed");
if (useridMode === "update" || conflictMode === "skip") {
skippedRecords.push(recordData);
} else if (conflictMode === "stop") {
throw new Error(
translate("import_users.error.id_exits", {
id: recordData.id,
})
);
} else {
const overwriteData = generateRandomUser();
const newRecordData = Object.assign({}, recordData, {
id: overwriteData.id,
});
retries++;
if (retries > 512) {
console.warn("retry loop got stuck? pathological situation?");
skippedRecords.push(recordData);
} else {
await submitRecord(newRecordData);
}
}
},
async okToSubmit => {
if (LOGGING)
console.log(
"OK to create record " +
recordData.id +
" (" +
recordData.displayname +
")."
);
if (!dryRun) {
await dataProvider.create("users", { data: recordData });
}
succeededRecords.push(recordData);
}
);
};
await submitRecord(userRecord);
entriesDone++;
setProgress({ done: entriesDone, limit: data.length });
}
setProgress(null);
} catch (e) {
setError(
translate("import_users.error.at_entry", {
entry: entriesDone + 1,
message: e.message,
})
);
setProgress(null);
}
return {
skippedRecords,
erroredRecords,
succeededRecords,
totalRecordCount: entriesCount,
changeStats,
wasDryRun: dryRun,
};
};
const downloadSkippedRecords = () => {
const element = document.createElement("a");
console.log(skippedRecords);
const file = new Blob([skippedRecords], {
type: "text/comma-separated-values",
});
element.href = URL.createObjectURL(file);
element.download = "skippedRecords.csv";
document.body.appendChild(element); // Required for this to work in FireFox
element.click();
};
const onConflictModeChanged = async e => {
if (progress !== null) {
return;
}
const value = e.target.value;
setConflictMode(value);
};
const onPasswordModeChange = e => {
if (progress !== null) {
return;
}
setPasswordMode(e.target.checked);
};
const onUseridModeChanged = async e => {
if (progress !== null) {
return;
}
const value = e.target.value;
setUseridMode(value);
};
const onDryRunModeChanged = ev => {
if (progress !== null) {
return;
}
setDryRun(ev.target.checked);
};
// render individual small components
const statsCards = stats &&
!importResults && [
<Container>
<CardHeader
title={translate("import_users.cards.importstats.header")}
/>
<CardContent>
<div>
{translate(
"import_users.cards.importstats.users_total",
stats.total
)}
</div>
<div>
{translate(
"import_users.cards.importstats.guest_count",
stats.is_guest
)}
</div>
<div>
{translate(
"import_users.cards.importstats.admin_count",
stats.admin
)}
</div>
</CardContent>
</Container>,
<Container>
<CardHeader title={translate("import_users.cards.ids.header")} />
<CardContent>
<div>
{stats.id === stats.total
? translate("import_users.cards.ids.all_ids_present")
: translate("import_users.cards.ids.count_ids_present", stats.id)}
</div>
{stats.id > 0 ? (
<div>
<NativeSelect
onChange={onUseridModeChanged}
value={useridMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="ignore"
text="import_users.cards.ids.mode.ignore"
/>
<TranslatableOption
value="update"
text="import_users.cards.ids.mode.update"
/>
</NativeSelect>
</div>
) : (
""
)}
</CardContent>
</Container>,
<Container>
<CardHeader title={translate("import_users.cards.passwords.header")} />
<CardContent>
<div>
{stats.password === stats.total
? translate("import_users.cards.passwords.all_passwords_present")
: translate(
"import_users.cards.passwords.count_passwords_present",
stats.password
)}
</div>
{stats.password > 0 ? (
<div>
<FormControlLabel
control={
<Checkbox
checked={passwordMode}
enabled={(progress !== null).toString()}
onChange={onPasswordModeChange}
/>
}
label={translate("import_users.cards.passwords.use_passwords")}
/>
</div>
) : (
""
)}
</CardContent>
</Container>,
];
let conflictCards = stats && !importResults && (
<Container>
<CardHeader title={translate("import_users.cards.conflicts.header")} />
<CardContent>
<div>
<NativeSelect
onChange={onConflictModeChanged}
value={conflictMode}
enabled={(progress !== null).toString()}
>
<TranslatableOption
value="stop"
text="import_users.cards.conflicts.mode.stop"
/>
<TranslatableOption
value="skip"
text="import_users.cards.conflicts.mode.skip"
/>
</NativeSelect>
</div>
</CardContent>
</Container>
);
let errorCards = error && (
<Container>
<CardHeader title={translate("import_users.error.error")} />
<CardContent>
{(Array.isArray(error) ? error : [error]).map(e => (
<div>{e}</div>
))}
</CardContent>
</Container>
);
let uploadCard = !importResults && (
<Container>
<CardHeader title={translate("import_users.cards.upload.header")} />
<CardContent>
{translate("import_users.cards.upload.explanation")}
<a href="./data/example.csv">example.csv</a>
<br />
<br />
<input
type="file"
onChange={onFileChange}
enabled={(progress !== null).toString()}
/>
</CardContent>
</Container>
);
let resultsCard = importResults && (
<CardContent>
<CardHeader title={translate("import_users.cards.results.header")} />
<div>
{translate(
"import_users.cards.results.total",
importResults.totalRecordCount
)}
<br />
{translate(
"import_users.cards.results.successful",
importResults.succeededRecords.length
)}
<br />
{importResults.skippedRecords.length
? [
translate(
"import_users.cards.results.skipped",
importResults.skippedRecords.length
),
<div>
<button onClick={downloadSkippedRecords}>
{translate("import_users.cards.results.download_skipped")}
</button>
</div>,
<br />,
]
: ""}
{importResults.erroredRecords.length
? [
translate(
"import_users.cards.results.skipped",
importResults.erroredRecords.length
),
<br />,
]
: ""}
<br />
{importResults.wasDryRun && [
translate("import_users.cards.results.simulated_only"),
<br />,
]}
</div>
</CardContent>
);
let startImportCard =
!values || values.length === 0 || importResults ? undefined : (
<CardActions>
<FormControlLabel
control={
<Checkbox
checked={dryRun}
onChange={onDryRunModeChanged}
enabled={(progress !== null).toString()}
/>
}
label={translate("import_users.cards.startImport.simulate_only")}
/>
<Button
size="large"
onClick={runImport}
enabled={(progress !== null).toString()}
>
{translate("import_users.cards.startImport.run_import")}
</Button>
{progress !== null ? (
<div>
{progress.done} of {progress.limit} done
</div>
) : null}
</CardActions>
);
let allCards = [];
if (uploadCard) allCards.push(uploadCard);
if (errorCards) allCards.push(errorCards);
if (conflictCards) allCards.push(conflictCards);
if (statsCards) allCards.push(...statsCards);
if (startImportCard) allCards.push(startImportCard);
if (resultsCard) allCards.push(resultsCard);
let cardContainer = <Card>{allCards}</Card>;
return [
<Title defaultTitle={translate("import_users.title")} />,
cardContainer,
];
};
export const ImportFeature = FilePicker;

View File

@@ -1,39 +0,0 @@
// in src/Menu.js
import * as React from "react";
import { useSelector } from "react-redux";
import { useMediaQuery } from "@material-ui/core";
import { MenuItemLink, getResources } from "react-admin";
import DefaultIcon from "@material-ui/icons/ViewList";
import LabelIcon from "@material-ui/icons/Label";
const Menu = ({ onMenuClick, logout }) => {
const isXSmall = useMediaQuery(theme => theme.breakpoints.down("xs"));
const open = useSelector(state => state.admin.ui.sidebarOpen);
const resources = useSelector(getResources);
return (
<div>
{resources.map(resource => (
<MenuItemLink
key={resource.name}
to={`/${resource.name}`}
primaryText={
(resource.options && resource.options.label) || resource.name
}
leftIcon={resource.icon ? <resource.icon /> : <DefaultIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
))}
<MenuItemLink
to="/custom-route"
primaryText="Miscellaneous"
leftIcon={<LabelIcon />}
onClick={onMenuClick}
sidebarIsOpen={open}
/>
{isXSmall && logout}
</div>
);
};
export default Menu;

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,210 @@
import React from "react";
import { Title, Button } from "react-admin";
import { makeStyles } from "@material-ui/core/styles";
import { PDFExport } from "@progress/kendo-react-pdf";
import QRCode from "qrcode.react";
function xor(a, b) {
var res = "";
for (var i = 0; i < a.length; i++) {
res += String.fromCharCode(a.charCodeAt(i) ^ b.charCodeAt(i % b.length));
}
return res;
}
function calculateQrString(serverUrl, username, password) {
const magicString = "wo9k5tep252qxsa5yde7366kugy6c01w7oeeya9hrmpf0t7ii7";
var urlString = "user=" + username + "&password=" + password;
urlString = xor(urlString, magicString); // xor with magic string
urlString = btoa(urlString); // to base64
return serverUrl + "/#" + urlString;
}
const ShowUserPdf = props => {
const useStyles = makeStyles(theme => ({
page: {
height: 800,
width: 566,
padding: "none",
backgroundColor: "white",
boxShadow: "5px 5px 5px black",
margin: "auto",
overflowX: "hidden",
overflowY: "hidden",
fontFamily: "DejaVu Sans, Sans-Serif",
fontSize: 15,
},
header: {
height: 144,
width: 534,
marginLeft: 32,
marginTop: 15,
},
name: {
width: 240,
fontSize: 35,
float: "left",
marginTop: 100,
},
logo: {
width: 90,
marginTop: 50,
marginRight: 70,
float: "right",
},
body: {
clear: "both",
},
table_cell: {
verticalAlign: "top",
},
code_note: {
marginLeft: 32,
marginTop: 86,
},
qr: {
marginTop: 15,
marginLeft: 32,
},
credentials_note: {
marginTop: 86,
marginLeft: 10,
},
credentials_text: {
marginLeft: 10,
fontSize: 12,
},
credentials: {
fontFamily: "DejaVu Sans Mono, monospace",
},
note: {
fontSize: 18,
marginTop: 100,
marginLeft: 32,
marginRight: 32,
},
}));
const classes = useStyles();
var resume;
const exportPDF = () => {
resume.save();
};
var qrCode = "";
var displayname = "";
var id = "";
var password = "";
var username = "";
var serverUrl = "";
if (
props.location.state &&
props.location.state.id &&
props.location.state.password
) {
id = props.location.state.id;
password = props.location.state.password;
username = id.substring(1, id.indexOf(":"));
serverUrl = "https://" + id.substring(id.indexOf(":") + 1);
const qrString = calculateQrString(serverUrl, username, password);
qrCode = <QRCode value={qrString} size={128} />;
displayname = props.location.state.displayname;
}
return (
<div>
<Title title="PDF" />
<Button label="synapseadmin.action.download_pdf" onClick={exportPDF} />
<PDFExport
paperSize={"A4"}
fileName="User.pdf"
title=""
subject=""
keywords=""
ref={r => (resume = r)}
>
<div className={classes.page}>
<div className={classes.header}>
<div className={classes.name}>{displayname}</div>
<img className={classes.logo} alt="Logo" src="images/logo.png" />
</div>
<div className={classes.body}>
<table>
<tbody>
<tr>
<td width="200px">
<div className={classes.code_note}>
Ihr persönlicher Anmeldecode:
</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_note}>
Ihre persönlichen Zugangsdaten:
</div>
</td>
</tr>
<tr>
<td>
<div className={classes.qr}>{qrCode}</div>
</td>
<td className={classes.table_cell}>
<div className={classes.credentials_text}>
<br />
<table>
<tbody>
<tr>
<td>Heimserver:</td>
<td>
<span className={classes.credentials}>
{serverUrl}
</span>
</td>
</tr>
<tr>
<td>Benutzername:</td>
<td>
<span className={classes.credentials}>
{username}
</span>
</td>
</tr>
<tr>
<td>Passwort:</td>
<td>
<span className={classes.credentials}>
{password}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
<div className={classes.note}>
Hier können Sie Ihre selbst gewählte
Schlüsselsicherungs-Passphrase notieren:
<br />
<br />
<br />
<hr />
</div>
</div>
</div>
</PDFExport>
</div>
);
};
export default ShowUserPdf;

View File

@@ -1,22 +1,25 @@
import React, { Fragment } from "react";
import React from "react";
import { connect } from "react-redux";
import {
AutocompleteArrayInput,
BooleanInput,
BooleanField,
BulkDeleteWithConfirmButton,
Create,
Datagrid,
DeleteButton,
Filter,
FormTab,
List,
Pagination,
ReferenceArrayInput,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectField,
Show,
Tab,
TabbedForm,
TabbedShowLayout,
TextField,
TopToolbar,
TextInput,
useTranslate,
} from "react-admin";
import get from "lodash/get";
@@ -58,40 +61,125 @@ const EncryptionField = ({ source, record = {}, emptyText }) => {
);
};
const RoomTitle = ({ record }) => {
const translate = useTranslate();
var name = "";
if (record) {
name = record.name !== "" ? record.name : record.id;
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;
}
}
return (
<span>
{translate("resources.rooms.name", 1)} {name}
</span>
);
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 RoomShowActions = ({ basePath, data, resource }) => {
const removeLeadingWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.trimStart();
const replaceAllWhitespace = fieldVal =>
fieldVal === undefined ? undefined : fieldVal.replace(/\s/, "_");
const removeLeadingSigil = fieldVal =>
fieldVal === undefined
? undefined
: fieldVal.startsWith("#")
? fieldVal.substr(1)
: fieldVal;
const validateHasAliasIfPublic = formdata => {
let errors = {};
if (formdata.public) {
if (
formdata.canonical_alias === undefined ||
formdata.canonical_alias.trim().length === 0
) {
errors.canonical_alias = "synapseadmin.rooms.alias_required_if_public";
}
}
return errors;
};
export const RoomCreate = props => (
<Create {...props}>
<TabbedForm validate={validateHasAliasIfPublic}>
<FormTab label="synapseadmin.rooms.details" icon={<ViewListIcon />}>
<TextInput
source="name"
parse={removeLeadingWhitespace}
validate={validateDisplayName}
/>
<TextInput
source="canonical_alias"
parse={fv => replaceAllWhitespace(removeLeadingSigil(fv))}
validate={validateAlias}
placeholder="#"
/>
<BooleanInput source="public" label="synapseadmin.rooms.make_public" />
<BooleanInput
source="encrypt"
initialValue={true}
label="synapseadmin.rooms.encrypt"
/>
</FormTab>
<FormTab
label="resources.rooms.fields.invite_members"
icon={<UserIcon />}
>
<ReferenceArrayInput
reference="users"
source="invitees"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteArrayInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceArrayInput>
</FormTab>
</TabbedForm>
</Create>
);
const RoomTitle = ({ record }) => {
const translate = useTranslate();
return (
<TopToolbar>
<DeleteButton
basePath={basePath}
record={data}
resource={resource}
undoable={false}
confirmTitle={translate("synapseadmin.rooms.delete.title")}
confirmContent={translate("synapseadmin.rooms.delete.message")}
/>
</TopToolbar>
<span>
{translate("resources.rooms.name", 1)} {record ? `"${record.name}"` : ""}
</span>
);
};
export const RoomShow = props => {
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<Show {...props} title={<RoomTitle />}>
<TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
@@ -201,18 +289,10 @@ export const RoomShow = props => {
</Show>
);
};
const RoomBulkActionButtons = props => (
<Fragment>
<BulkDeleteWithConfirmButton {...props} />
</Fragment>
);
const RoomFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
<Chip
label={translate("resources.rooms.fields.joined_local_members")}
source="joined_local_members"
@@ -248,7 +328,6 @@ const FilterableRoomList = ({ ...props }) => {
const stateEventsFilter = filter && filter.state_events ? true : false;
const versionFilter = filter && filter.version ? true : false;
const federateableFilter = filter && filter.federatable ? true : false;
const translate = useTranslate();
return (
<List
@@ -256,12 +335,6 @@ const FilterableRoomList = ({ ...props }) => {
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={<RoomFilter />}
bulkActionButtons={
<RoomBulkActionButtons
confirmTitle={translate("synapseadmin.rooms.delete.title")}
confirmContent={translate("synapseadmin.rooms.delete.message")}
/>
}
>
<Datagrid rowClick="show">
<EncryptionField

View File

@@ -1,42 +0,0 @@
import React from "react";
import {
Datagrid,
Filter,
List,
NumberField,
TextField,
SearchInput,
Pagination,
} from "react-admin";
const UserMediaStatsPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserMediaStatsFilter = props => (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
</Filter>
);
export const UserMediaStatsList = props => {
return (
<List
{...props}
filters={<UserMediaStatsFilter />}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick={(id, basePath, record) => "/users/" + id + "/media"}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
};

View File

@@ -3,15 +3,10 @@ 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 GetAppIcon from "@material-ui/icons/GetApp";
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
import NotificationsIcon from "@material-ui/icons/Notifications";
import PermMediaIcon from "@material-ui/icons/PermMedia";
import ViewListIcon from "@material-ui/icons/ViewList";
import {
ArrayInput,
ArrayField,
Button,
Datagrid,
DateField,
Create,
@@ -28,33 +23,26 @@ import {
PasswordInput,
TextField,
TextInput,
SearchInput,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectInput,
BulkDeleteButton,
DeleteButton,
SaveButton,
regex,
useRedirect,
useTranslate,
Pagination,
CreateButton,
ExportButton,
TopToolbar,
sanitizeListRestProps,
NumberField,
} from "react-admin";
import SaveQrButton from "./SaveQrButton";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices";
import { makeStyles } from "@material-ui/core/styles";
const redirect = (basePath, id, data) => {
return {
pathname: "/import_users",
};
};
const useStyles = makeStyles({
small: {
height: "40px",
@@ -84,44 +72,27 @@ const UserListActions = ({
maxResults,
total,
...rest
}) => {
const redirectTo = useRedirect();
return (
<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}
/>
{/* Add your custom actions */}
<Button
onClick={() => {
redirectTo(redirect);
}}
label="CSV Import"
>
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</Button>
</TopToolbar>
);
};
UserListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
};
}) => (
<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]} />
@@ -129,7 +100,7 @@ const UserPagination = props => (
const UserFilter = props => (
<Filter {...props}>
<SearchInput source="name" alwaysOn />
<SearchInput source="user_id" alwaysOn />
<BooleanInput source="guests" alwaysOn />
<BooleanInput
label="resources.users.fields.show_deactivated"
@@ -175,7 +146,7 @@ export const UserList = props => {
className={classes.small}
/>
<TextField source="id" sortable={false} />
<TextField source="displayname" sortable={false} />
<TextField source="displayname" />
<BooleanField source="is_guest" sortable={false} />
<BooleanField source="admin" sortable={false} />
<BooleanField source="deactivated" sortable={false} />
@@ -184,13 +155,7 @@ export const UserList = props => {
);
};
// https://matrix.org/docs/spec/appendices#user-identifiers
const validateUser = regex(
/^@[a-z0-9._=\-/]+:.*/,
"synapseadmin.users.invalid_user_id"
);
export function generateRandomUser() {
function generateRandomUser() {
const homeserver = localStorage.getItem("home_server");
const user_id =
"@" +
@@ -231,11 +196,55 @@ export function generateRandomUser() {
};
}
// redirect to the related Author show page
const redirect = (basePath, id, data) => {
return {
pathname: "/showpdf",
state: {
id: data.id,
displayname: data.displayname,
password: data.password,
},
};
};
const UserCreateToolbar = props => (
<Toolbar {...props}>
<SaveQrButton
label="synapseadmin.action.save_and_show"
redirect={redirect}
submitOnEnter={true}
/>
<SaveButton
label="synapseadmin.action.save_only"
redirect="list"
submitOnEnter={false}
variant="text"
/>
</Toolbar>
);
// https://matrix.org/docs/spec/appendices#user-identifiers
const validateUser = regex(
/^@[a-z0-9._=\-/]+:.*/,
"synapseadmin.users.invalid_user_id"
);
const UserEditToolbar = props => {
const translate = useTranslate();
return (
<Toolbar {...props}>
<SaveButton submitOnEnter={true} />
<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")}
@@ -246,8 +255,8 @@ const UserEditToolbar = props => {
};
export const UserCreate = props => (
<Create {...props}>
<SimpleForm>
<Create record={generateRandomUser()} {...props}>
<SimpleForm toolbar={<UserCreateToolbar />}>
<TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" />
@@ -279,6 +288,7 @@ const UserTitle = ({ record }) => {
</span>
);
};
export const UserEdit = props => {
const classes = useStyles();
const translate = useTranslate();
@@ -316,7 +326,6 @@ export const UserEdit = props => {
/>
<TextField source="consent_version" />
</FormTab>
<FormTab
label="resources.users.threepid"
icon={<ContactMailIcon />}
@@ -335,7 +344,6 @@ export const UserEdit = props => {
</SimpleFormIterator>
</ArrayInput>
</FormTab>
<FormTab
label={translate("resources.devices.name", { smart_count: 2 })}
icon={<DevicesIcon />}
@@ -367,7 +375,6 @@ export const UserEdit = props => {
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label="resources.connections.name"
icon={<SettingsInputComponentIcon />}
@@ -407,111 +414,6 @@ export const UserEdit = props => {
</ArrayField>
</ReferenceField>
</FormTab>
<FormTab
label={translate("resources.users_media.name", { smart_count: 2 })}
icon={<PermMediaIcon />}
path="media"
>
<ReferenceManyField
reference="users_media"
target="user_id"
addLabel={false}
pagination={<UserPagination />}
perPage={50}
>
<Datagrid style={{ width: "100%" }}>
<DateField
source="created_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<DateField
source="last_access_ts"
showTime
options={{
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}}
sortable={false}
/>
<TextField source="media_id" sortable={false} />
<NumberField source="media_length" sortable={false} />
<TextField source="media_type" sortable={false} />
<TextField source="upload_name" sortable={false} />
<TextField source="quarantined_by" sortable={false} />
<BooleanField source="safe_from_quarantine" sortable={false} />
<DeleteButton undoable={false} redirect={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.rooms.name", { smart_count: 2 })}
icon={<ViewListIcon />}
path="rooms"
>
<ReferenceManyField
reference="joined_rooms"
target="user_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}
>
<TextField
source="id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<ReferenceField
label="resources.rooms.fields.name"
source="id"
reference="rooms"
sortable={false}
link=""
>
<TextField source="name" sortable={false} />
</ReferenceField>
</Datagrid>
</ReferenceManyField>
</FormTab>
<FormTab
label={translate("resources.pushers.name", { smart_count: 2 })}
icon={<NotificationsIcon />}
path="pushers"
>
<ReferenceManyField
reference="pushers"
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
<TextField source="data.url" sortable={false} />
<TextField source="device_display_name" sortable={false} />
<TextField source="lang" sortable={false} />
<TextField source="profile_tag" sortable={false} />
<TextField source="pushkey" sortable={false} />
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
);

View File

@@ -11,92 +11,31 @@ export default {
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
url_error: "Keine gültige Matrix Server URL",
},
action: {
save_and_show: "Speichern und QR Code erzeugen",
save_only: "Nur speichern",
download_pdf: "PDF speichern",
},
users: {
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",
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",
},
delete: {
title: "Raum löschen",
message:
"Sind Sie sicher dass Sie den Raum löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten und Medien, die der Raum beinhaltet werden vom Server gelöscht!",
},
},
reports: { tabs: { basic: "Allgemein", detail: "Details" } },
},
import_users: {
error: {
at_entry: "Bei Eintrag %{entry}: %{message}",
error: "Fehler",
required_field: "Pflichtfeld '%{field}' fehlt",
invalid_value:
"Ungültiger Wert in Zeile %{row}. Feld '%{field}' darf nur die Werte 'true' oder 'false' enthalten",
unreasonably_big: "Datei ist zu groß für den Import (%{size} Megabytes)",
already_in_progress: "Es läuft bereits ein Import",
id_exits: "ID %{id} existiert bereits",
},
title: "Benutzer aus CSV importieren",
goToPdf: "Gehe zum PDF",
cards: {
importstats: {
header: "Benutzer importieren",
users_total:
"%{smart_count} Benutzer in der CSV Datei |||| %{smart_count} Benutzer in der CSV Datei",
guest_count: "%{smart_count} Gast |||| %{smart_count} Gäste",
admin_count:
"%{smart_count} Server Administrator |||| %{smart_count} Server Administratoren",
},
conflicts: {
header: "Konfliktstrategie",
mode: {
stop: "Stoppe bei Fehlern",
skip: "Zeige Fehler und überspringe fehlerhafte Einträge",
},
},
ids: {
header: "IDs",
all_ids_present: "IDs in jedem Eintrag vorhanden",
count_ids_present:
"%{smart_count} Eintrag mit ID |||| %{smart_count} Einträge mit IDs",
mode: {
ignore: "Ignoriere IDs der CSV-Datei und erstelle neue",
update: "Aktualisiere existierende Benutzer",
},
},
passwords: {
header: "Passwörter",
all_passwords_present: "Passwörter in jedem Eintrag vorhanden",
count_passwords_present:
"%{smart_count} Eintrag mit Passwort |||| %{smart_count} Einträge mit Passwörtern",
use_passwords: "Verwende Passwörter aus der CSV Datei",
},
upload: {
header: "CSV Datei importieren",
explanation:
"Hier können Sie eine Datei mit kommagetrennten Daten hochladen, die verwendet werden um Benutzer anzulegen oder zu ändern. Die Datei muss mindestens die Felder 'id' und 'displayname' enthalten. Hier können Sie eine Beispieldatei herunterladen und anpassen: ",
},
startImport: {
simulate_only: "Nur simulieren",
run_import: "Importieren",
},
results: {
header: "Ergebnis",
total:
"%{smart_count} Eintrag insgesamt |||| %{smart_count} Einträge insgesamt",
successful: "%{smart_count} Einträge erfolgreich importiert",
skipped: "%{smart_count} Einträge übersprungen",
download_skipped: "Übersprungene Einträge herunterladen",
with_error:
"%{smart_count} Eintrag mit Fehlern ||| %{smart_count} Einträge mit Fehlern",
simulated_only: "Import-Vorgang war nur simuliert",
},
},
},
resources: {
@@ -125,10 +64,14 @@ export default {
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:
"Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
},
action: {
@@ -142,6 +85,8 @@ 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",
@@ -174,30 +119,6 @@ export default {
unencrypted: "Nicht verschlüsselt",
},
},
reports: {
name: "Ereignisbericht |||| Ereignisberichte",
fields: {
id: "ID",
received_ts: "Meldezeit",
user_id: "Meldender",
name: "Raumname",
score: "Wert",
reason: "Grund",
event_id: "Event-ID",
event_json: {
origin: "Ursprungsserver",
origin_server_ts: "Sendezeit",
type: "Eventtyp",
content: {
msgtype: "Inhaltstyp",
body: "Nachrichteninhalt",
format: "Nachrichtenformat",
formatted_body: "Formatierter Nachrichteninhalt",
algorithm: "Verschlüsselungsalgorithmus",
},
},
},
},
connections: {
name: "Verbindungen",
fields: {
@@ -208,12 +129,6 @@ export default {
},
devices: {
name: "Gerät |||| Geräte",
fields: {
device_id: "Geräte-ID",
display_name: "Gerätename",
last_seen_ts: "Zeitstempel",
last_seen_ip: "IP-Adresse",
},
action: {
erase: {
title: "Entferne %{id}",
@@ -223,33 +138,6 @@ export default {
},
},
},
users_media: {
name: "Medien",
fields: {
media_id: "Medien ID",
media_length: "Größe",
media_type: "Typ",
upload_name: "Dateiname",
quarantined_by: "Zur Quarantäne hinzugefügt",
safe_from_quarantine: "Geschützt vor Quarantäne",
created_ts: "Erstellt",
last_access_ts: "Letzter Zugriff",
},
},
pushers: {
name: "Pusher |||| Pushers",
fields: {
app: "App",
app_display_name: "App-Anzeigename",
app_id: "App ID",
device_display_name: "Geräte-Anzeigename",
kind: "Art",
lang: "Sprache",
profile_tag: "Profil-Tag",
pushkey: "Pushkey",
data: { url: "URL" },
},
},
servernotices: {
name: "Serverbenachrichtigungen",
send: "Servernachricht versenden",
@@ -266,20 +154,9 @@ export default {
'Sendet eine Serverbenachrichtigung an die ausgewählten Nutzer. Hierfür muss das Feature "Server Notices" auf dem Server aktiviert sein.',
},
},
user_media_statistics: {
name: "Dateien je Benutzer",
fields: {
media_count: "Anzahl der Dateien",
media_length: "Größe der Dateien",
},
},
},
ra: {
...germanMessages.ra,
action: {
...germanMessages.ra.action,
unselect: "Abwählen",
},
auth: {
...germanMessages.ra.auth,
auth_check_error: "Anmeldung fehlgeschlagen",
@@ -296,10 +173,5 @@ export default {
...germanMessages.ra.notifiaction,
logged_out: "Abgemeldet",
},
page: {
...germanMessages.ra.page,
empty: "Keine Einträge vorhanden",
invite: "",
},
},
};

View File

@@ -11,91 +11,31 @@ export default {
protocol_error: "URL has to start with 'http://' or 'https://'",
url_error: "Not a valid Matrix server URL",
},
action: {
save_and_show: "Create QR code",
save_only: "Save",
download_pdf: "Download PDF",
},
users: {
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",
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",
},
delete: {
title: "Delete room",
message:
"Are you sure you want to delete the room? This cannot be undone. All messages and shared media in the room will be deleted from the server!",
},
},
reports: { tabs: { basic: "Basic", detail: "Details" } },
},
import_users: {
error: {
at_entry: "At entry %{entry}: %{message}",
error: "Error",
required_field: "Required field '%{field}' is not present",
invalid_value:
"Invalid value on line %{row}. '%{field}' field may only be 'true' or 'false'",
unreasonably_big:
"Refused to load unreasonably big file of %{size} megabytes",
already_in_progress: "An import run is already in progress",
id_exits: "ID %{id} already present",
},
title: "Import users via CSV",
goToPdf: "Go to PDF",
cards: {
importstats: {
header: "Import users",
users_total:
"%{smart_count} user in CSV file |||| %{smart_count} users in CSV file",
guest_count: "%{smart_count} guest |||| %{smart_count} guests",
admin_count: "%{smart_count} admin |||| %{smart_count} admins",
},
conflicts: {
header: "Conflict strategy",
mode: {
stop: "Stop on conflict",
skip: "Show error and skip on conflict",
},
},
ids: {
header: "IDs",
all_ids_present: "IDs present on every entry",
count_ids_present:
"%{smart_count} entry with ID |||| %{smart_count} entries with IDs",
mode: {
ignore: "Ignore IDs in CSV and create new ones",
update: "Update existing records",
},
},
passwords: {
header: "Passwords",
all_passwords_present: "Passwords present on every entry",
count_passwords_present:
"%{smart_count} entry with password |||| %{smart_count} entries with passwords",
use_passwords: "Use passwords from CSV",
},
upload: {
header: "Input CSV file",
explanation:
"Here you can upload a file with comma separated values that is processed to create or update users. The file must include the fields 'id' and 'displayname'. You can download and adapt an example file here: ",
},
startImport: {
simulate_only: "Simulate only",
run_import: "Import",
},
results: {
header: "Import results",
total:
"%{smart_count} entry in total |||| %{smart_count} entries in total",
successful: "%{smart_count} entries successfully imported",
skipped: "%{smart_count} entries skipped",
download_skipped: "Download skipped records",
with_error:
"%{smart_count} entry with errors ||| %{smart_count} entries with errors",
simulated_only: "Run was only simulated",
},
},
},
resources: {
@@ -124,9 +64,14 @@ export default {
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: "You must provide a password to re-activate an account.",
deactivate: "Deactivated users cannot be reactivated",
erase: "Mark the user as GDPR-erased",
},
action: {
@@ -140,7 +85,9 @@ export default {
name: "Name",
canonical_alias: "Alias",
joined_members: "Members",
joined_local_members: "Local members",
invite_members: "Invite Members",
invitees: "Invitations",
joined_local_members: "local members",
state_events: "State events",
version: "Version",
is_encrypted: "Encrypted",
@@ -172,30 +119,6 @@ export default {
unencrypted: "Unencrypted",
},
},
reports: {
name: "Reported event |||| Reported events",
fields: {
id: "ID",
received_ts: "report time",
user_id: "announcer",
name: "name of the room",
score: "score",
reason: "reason",
event_id: "event ID",
event_json: {
origin: "origin server",
origin_server_ts: "time of send",
type: "event typ",
content: {
msgtype: "content type",
body: "content",
format: "format",
formatted_body: "formatted content",
algorithm: "algorithm",
},
},
},
},
connections: {
name: "Connections",
fields: {
@@ -206,12 +129,6 @@ export default {
},
devices: {
name: "Device |||| Devices",
fields: {
device_id: "Device-ID",
display_name: "Device name",
last_seen_ts: "Timestamp",
last_seen_ip: "IP address",
},
action: {
erase: {
title: "Removing %{id}",
@@ -221,33 +138,6 @@ export default {
},
},
},
users_media: {
name: "Media",
fields: {
media_id: "Media ID",
media_length: "Lenght",
media_type: "Type",
upload_name: "File name",
quarantined_by: "Quarantined by",
safe_from_quarantine: "Safe from quarantine",
created_ts: "Created",
last_access_ts: "Last access",
},
},
pushers: {
name: "Pusher |||| Pushers",
fields: {
app: "App",
app_display_name: "App display name",
app_id: "App ID",
device_display_name: "Device display name",
kind: "Kind",
lang: "Language",
profile_tag: "Profile tag",
pushkey: "Pushkey",
data: { url: "URL" },
},
},
servernotices: {
name: "Server Notices",
send: "Send server notices",
@@ -264,12 +154,5 @@ export default {
'Sends a server notice to the selected users. The feature "Server Notices" has to be activated at the server.',
},
},
user_media_statistics: {
name: "Users' media",
fields: {
media_count: "Media count",
media_length: "Media length",
},
},
},
};

View File

@@ -10,7 +10,6 @@ const authProvider = {
type: "m.login.password",
user: username,
password: password,
initial_device_display_name: "Synapse Admin",
}),
};
@@ -31,24 +30,9 @@ const authProvider = {
},
// called when the user clicks on the logout button
logout: () => {
console.log("logout");
const logout_api_url =
localStorage.getItem("base_url") + "/_matrix/client/r0/logout";
const token = localStorage.getItem("access_token");
const options = {
method: "POST",
user: {
authenticated: true,
token: `Bearer ${token}`,
},
};
return fetchUtils.fetchJson(logout_api_url, options).then(({ json }) => {
localStorage.removeItem("access_token");
localStorage.removeItem("device_id");
});
console.log("logout ");
localStorage.removeItem("access_token");
return Promise.resolve();
},
// called when the API returns an error
checkError: ({ status }) => {

View File

@@ -35,6 +35,7 @@ const resourceMap = {
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,
}),
@@ -63,33 +64,38 @@ const resourceMap = {
public: !!r.public,
}),
data: "rooms",
total: json => {
return json.total_rooms;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/delete`,
body: { block: false },
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,
initial_state: data.encrypt
? [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
]
: undefined,
},
method: "POST",
}),
},
reports: {
path: "/_synapse/admin/v1/event_reports",
map: er => ({
...er,
id: er.id,
}),
data: "event_reports",
total: json => json.total,
},
devices: {
map: d => ({
...d,
id: d.device_id,
}),
data: "devices",
total: json => {
return json.total;
},
reference: id => ({
endpoint: `/_synapse/admin/v2/users/${id}/devices`,
}),
@@ -113,52 +119,6 @@ const resourceMap = {
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
}),
data: "members",
total: json => {
return json.total;
},
},
pushers: {
map: p => ({
...p,
id: p.pushkey,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${id}/pushers`,
}),
data: "pushers",
total: json => {
return json.total;
},
},
joined_rooms: {
map: jr => ({
id: jr,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${id}/joined_rooms`,
}),
data: "joined_rooms",
total: json => {
return json.total;
},
},
users_media: {
map: um => ({
...um,
id: um.media_id,
}),
reference: id => ({
endpoint: `/_synapse/admin/v1/users/${id}/media`,
}),
data: "media",
total: json => {
return json.total;
},
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/${params.id}`,
}),
},
servernotices: {
map: n => ({ id: n.event_id }),
@@ -174,17 +134,6 @@ const resourceMap = {
method: "POST",
}),
},
user_media_statistics: {
path: "/_synapse/admin/v1/statistics/users/media",
map: usms => ({
...usms,
id: usms.user_id,
}),
data: "users",
total: json => {
return json.total;
},
},
};
function filterNullValues(key, value) {
@@ -206,7 +155,7 @@ function getSearchOrder(order) {
const dataProvider = {
getList: (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, search_term } = params.filter;
const { user_id, guests, deactivated } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const from = (page - 1) * perPage;
@@ -214,8 +163,6 @@ const dataProvider = {
from: from,
limit: perPage,
user_id: user_id,
search_term: search_term,
name: name,
guests: guests,
deactivated: deactivated,
order_by: field,
@@ -260,18 +207,11 @@ const dataProvider = {
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`))
).then(responses => ({
data: responses.map(({ json }) => res.map(json)),
total: responses.length,
}));
},
getManyReference: (resource, params) => {
console.log("getManyReference " + resource);
const { page, perPage } = params.pagination;
const from = (page - 1) * perPage;
const query = {
from: from,
limit: perPage,
};
const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@@ -279,11 +219,10 @@ const dataProvider = {
const res = resourceMap[resource];
const ref = res["reference"](params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const endpoint_url = homeserver + ref.endpoint;
return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map),
total: res.total(json, from, perPage),
}));
},

2152
yarn.lock

File diff suppressed because it is too large Load Diff