Merge pull request #29 from Awesome-Technologies/master
import orign/master
This commit is contained in:
commit
e88ee20a19
@ -1,5 +1,4 @@
|
|||||||
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
# Exclude a bunch of stuff which can make the build context a larger than it needs to be
|
||||||
.git/
|
|
||||||
tests/
|
tests/
|
||||||
build/
|
build/
|
||||||
lib/
|
lib/
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Builder
|
# Builder
|
||||||
FROM node:10-alpine as builder
|
FROM node:current as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
43
README.md
43
README.md
@ -4,6 +4,45 @@
|
|||||||
|
|
||||||
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
This project is built using [react-admin](https://marmelab.com/react-admin/).
|
||||||
|
|
||||||
Use `yarn install` after cloning this repo.
|
It needs at least Synapse v1.18.0 for all functions to work as expected!
|
||||||
|
|
||||||
Use `yarn start` to launch the webserver.
|
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)
|
||||||
|
|
||||||
|
## Step-By-Step install:
|
||||||
|
|
||||||
|
You have two options:
|
||||||
|
|
||||||
|
1. Download the source code from github and run using nodejs
|
||||||
|
2. Run the Docker container
|
||||||
|
|
||||||
|
Steps for 1):
|
||||||
|
|
||||||
|
- make sure you have installed the following: git, yarn, nodejs
|
||||||
|
- download the source code: `git clone https://github.com/Awesome-Technologies/synapse-admin.git`
|
||||||
|
- change into downloaded directory: `cd synapse-admin`
|
||||||
|
- download dependencies: `yarn install`
|
||||||
|
- start web server: `yarn start`
|
||||||
|
|
||||||
|
Steps for 2):
|
||||||
|
|
||||||
|
- run the Docker container: `docker run -p 8080:80 awesometechnologies/synapse-admin`
|
||||||
|
- browse to http://localhost:8080
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
![Screenshots](./screenshots.jpg)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Use `yarn test` to run all style, lint and unit tests
|
||||||
|
- Use `yarn fix` to fix the coding style
|
||||||
|
16
package.json
16
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "synapse-admin",
|
"name": "synapse-admin",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"description": "Admin GUI for the Matrix.org server Synapse",
|
"description": "Admin GUI for the Matrix.org server Synapse",
|
||||||
"author": "Awesome Technologies Innovationslabor GmbH",
|
"author": "Awesome Technologies Innovationslabor GmbH",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@ -12,25 +12,27 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^5.1.1",
|
"@testing-library/jest-dom": "^5.1.1",
|
||||||
"@testing-library/react": "^10.0.2",
|
"@testing-library/react": "^10.0.2",
|
||||||
"@testing-library/user-event": "^10.0.1",
|
"@testing-library/user-event": "^12.0.11",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"enzyme-adapter-react-16": "^1.15.2",
|
"enzyme-adapter-react-16": "^1.15.2",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-config-prettier": "^6.10.1",
|
"eslint-config-prettier": "^6.10.1",
|
||||||
"eslint-plugin-prettier": "^3.1.2",
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"prettier": "^2.0.0"
|
"prettier": "^2.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"papaparse": "^5.2.0",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"ra-language-german": "^2.1.2",
|
"ra-language-german": "^2.1.2",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-admin": "^3.4.0",
|
"react-admin": "^3.10.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.14.0",
|
||||||
"react-scripts": "^3.4.1"
|
"react-scripts": "^3.4.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "REACT_APP_VERSION=$(git describe --tags) react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "REACT_APP_VERSION=$(git describe --tags) react-scripts build",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
"fix:code": "yarn test:lint --fix",
|
"fix:code": "yarn test:lint --fix",
|
||||||
"fix": "yarn fix:code && yarn fix:other",
|
"fix": "yarn fix:code && yarn fix:other",
|
||||||
|
3
public/data/example.csv
Normal file
3
public/data/example.csv
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
id,displayname,password,is_guest,admin,deactivated
|
||||||
|
@testuser22:example.org,Jane Doe,secretpassword,false,true,false
|
||||||
|
,John Doe,,false,false,false
|
|
@ -38,5 +38,12 @@
|
|||||||
To begin the development, run `npm start` or `yarn start`.
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
-->
|
-->
|
||||||
|
<footer
|
||||||
|
style="position: relative; z-index: 2; height: 2em; margin-top: -2em; line-height: 2em; background-color: #eee; border: 0.5px solid #ddd">
|
||||||
|
<a id="copyright" href="https://github.com/Awesome-Technologies/synapse-admin"
|
||||||
|
style="margin-left: 1em; color: #888; font-family: Roboto, Helvetica, Arial, sans-serif; font-weight: 100; font-size: 0.8em; text-decoration: none;">
|
||||||
|
Synapse-Admin <b>(%REACT_APP_VERSION%)</b> by Awesome Technologies Innovationslabor GmbH
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
BIN
screenshots.jpg
Normal file
BIN
screenshots.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 222 KiB |
11
src/App.js
11
src/App.js
@ -4,10 +4,12 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
|
|||||||
import authProvider from "./synapse/authProvider";
|
import authProvider from "./synapse/authProvider";
|
||||||
import dataProvider from "./synapse/dataProvider";
|
import dataProvider from "./synapse/dataProvider";
|
||||||
import { UserList, UserCreate, UserEdit } from "./components/users";
|
import { UserList, UserCreate, UserEdit } from "./components/users";
|
||||||
import { RoomList } from "./components/rooms";
|
import { RoomList, RoomShow } from "./components/rooms";
|
||||||
import LoginPage from "./components/LoginPage";
|
import LoginPage from "./components/LoginPage";
|
||||||
import UserIcon from "@material-ui/icons/Group";
|
import UserIcon from "@material-ui/icons/Group";
|
||||||
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
|
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
|
||||||
|
import { ImportFeature } from "./components/ImportFeature";
|
||||||
|
import { Route } from "react-router-dom";
|
||||||
import germanMessages from "./i18n/de";
|
import germanMessages from "./i18n/de";
|
||||||
import englishMessages from "./i18n/en";
|
import englishMessages from "./i18n/en";
|
||||||
|
|
||||||
@ -27,6 +29,9 @@ const App = () => (
|
|||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
|
customRoutes={[
|
||||||
|
<Route key="userImport" path="/import_users" component={ImportFeature} />,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Resource
|
<Resource
|
||||||
name="users"
|
name="users"
|
||||||
@ -35,8 +40,10 @@ const App = () => (
|
|||||||
edit={UserEdit}
|
edit={UserEdit}
|
||||||
icon={UserIcon}
|
icon={UserIcon}
|
||||||
/>
|
/>
|
||||||
<Resource name="rooms" list={RoomList} icon={RoomIcon} />
|
<Resource name="rooms" list={RoomList} show={RoomShow} icon={RoomIcon} />
|
||||||
<Resource name="connections" />
|
<Resource name="connections" />
|
||||||
|
<Resource name="devices" />
|
||||||
|
<Resource name="room_members" />
|
||||||
<Resource name="servernotices" />
|
<Resource name="servernotices" />
|
||||||
</Admin>
|
</Admin>
|
||||||
);
|
);
|
||||||
|
674
src/components/ImportFeature.js
Normal file
674
src/components/ImportFeature.js
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
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;
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
fetchUtils,
|
fetchUtils,
|
||||||
FormDataConsumer,
|
FormDataConsumer,
|
||||||
@ -29,7 +29,7 @@ const useStyles = makeStyles(theme => ({
|
|||||||
main: {
|
main: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
minHeight: "100vh",
|
minHeight: "calc(100vh - 1em)",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
background: "url(./images/floating-cogs.svg)",
|
background: "url(./images/floating-cogs.svg)",
|
||||||
@ -40,6 +40,7 @@ const useStyles = makeStyles(theme => ({
|
|||||||
card: {
|
card: {
|
||||||
minWidth: "30em",
|
minWidth: "30em",
|
||||||
marginTop: "6em",
|
marginTop: "6em",
|
||||||
|
marginBottom: "6em",
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
margin: "1em",
|
margin: "1em",
|
||||||
@ -64,6 +65,12 @@ const useStyles = makeStyles(theme => ({
|
|||||||
actions: {
|
actions: {
|
||||||
padding: "0 1em 1em 1em",
|
padding: "0 1em 1em 1em",
|
||||||
},
|
},
|
||||||
|
serverVersion: {
|
||||||
|
color: "#9e9e9e",
|
||||||
|
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
marginBottom: "1em",
|
||||||
|
marginLeft: "0.5em",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const LoginPage = ({ theme }) => {
|
const LoginPage = ({ theme }) => {
|
||||||
@ -137,6 +144,7 @@ const LoginPage = ({ theme }) => {
|
|||||||
|
|
||||||
const UserData = ({ formData }) => {
|
const UserData = ({ formData }) => {
|
||||||
const form = useForm();
|
const form = useForm();
|
||||||
|
const [serverVersion, setServerVersion] = useState("");
|
||||||
|
|
||||||
const handleUsernameChange = _ => {
|
const handleUsernameChange = _ => {
|
||||||
if (formData.base_url) return;
|
if (formData.base_url) return;
|
||||||
@ -157,6 +165,30 @@ const LoginPage = ({ theme }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
_ => {
|
||||||
|
if (
|
||||||
|
!formData.base_url ||
|
||||||
|
!formData.base_url.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+$/)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const versionUrl = `${formData.base_url}/_synapse/admin/v1/server_version`;
|
||||||
|
fetchUtils
|
||||||
|
.fetchJson(versionUrl, { method: "GET" })
|
||||||
|
.then(({ json }) => {
|
||||||
|
setServerVersion(
|
||||||
|
`${translate("synapseadmin.auth.server_version")} ${
|
||||||
|
json["server_version"]
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(_ => {
|
||||||
|
setServerVersion("");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[formData.base_url]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={classes.input}>
|
<div className={classes.input}>
|
||||||
@ -189,6 +221,7 @@ const LoginPage = ({ theme }) => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={classes.serverVersion}>{serverVersion}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
39
src/components/Menu.js
Normal file
39
src/components/Menu.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// 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;
|
@ -7,8 +7,10 @@ import {
|
|||||||
Toolbar,
|
Toolbar,
|
||||||
required,
|
required,
|
||||||
useCreate,
|
useCreate,
|
||||||
|
useMutation,
|
||||||
useNotify,
|
useNotify,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
|
useUnselectAll,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import MessageIcon from "@material-ui/icons/Message";
|
import MessageIcon from "@material-ui/icons/Message";
|
||||||
import IconCancel from "@material-ui/icons/Cancel";
|
import IconCancel from "@material-ui/icons/Cancel";
|
||||||
@ -98,3 +100,49 @@ export const ServerNoticeButton = ({ record }) => {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ServerNoticeBulkButton = ({ selectedIds }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const notify = useNotify();
|
||||||
|
const unselectAll = useUnselectAll();
|
||||||
|
const [createMany, { loading }] = useMutation();
|
||||||
|
|
||||||
|
const handleDialogOpen = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleSend = values => {
|
||||||
|
createMany(
|
||||||
|
{
|
||||||
|
type: "createMany",
|
||||||
|
resource: "servernotices",
|
||||||
|
payload: { ids: selectedIds, data: values },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
notify("resources.servernotices.action.send_success");
|
||||||
|
unselectAll("users");
|
||||||
|
handleDialogClose();
|
||||||
|
},
|
||||||
|
onFailure: error =>
|
||||||
|
notify("resources.servernotices.action.send_failure", "error"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
label="resources.servernotices.send"
|
||||||
|
onClick={handleDialogOpen}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<MessageIcon />
|
||||||
|
</Button>
|
||||||
|
<ServerNoticeDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
onSend={handleSend}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
89
src/components/devices.js
Normal file
89
src/components/devices.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React, { Fragment, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
useMutation,
|
||||||
|
useNotify,
|
||||||
|
Confirm,
|
||||||
|
useRefresh,
|
||||||
|
} from "react-admin";
|
||||||
|
import ActionDelete from "@material-ui/icons/Delete";
|
||||||
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
|
import { fade } from "@material-ui/core/styles/colorManipulator";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
|
const useStyles = makeStyles(
|
||||||
|
theme => ({
|
||||||
|
deleteButton: {
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: fade(theme.palette.error.main, 0.12),
|
||||||
|
// Reset on mouse devices
|
||||||
|
"@media (hover: none)": {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: "RaDeleteDeviceButton" }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DeviceRemoveButton = props => {
|
||||||
|
const { record } = props;
|
||||||
|
const classes = useStyles(props);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const refresh = useRefresh();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
const [removeDevice, { loading }] = useMutation();
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const handleClick = () => setOpen(true);
|
||||||
|
const handleDialogClose = () => setOpen(false);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
removeDevice(
|
||||||
|
{
|
||||||
|
type: "delete",
|
||||||
|
resource: "devices",
|
||||||
|
payload: {
|
||||||
|
id: record.id,
|
||||||
|
user_id: record.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notify("resources.devices.action.erase.success");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onFailure: () =>
|
||||||
|
notify("resources.devices.action.erase.failure", "error"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Button
|
||||||
|
label="ra.action.remove"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={classnames("ra-delete-button", classes.deleteButton)}
|
||||||
|
>
|
||||||
|
<ActionDelete />
|
||||||
|
</Button>
|
||||||
|
<Confirm
|
||||||
|
isOpen={open}
|
||||||
|
loading={loading}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
title="resources.devices.action.erase.title"
|
||||||
|
content="resources.devices.action.erase.content"
|
||||||
|
translateOptions={{
|
||||||
|
id: record.id,
|
||||||
|
name: record.display_name ? record.display_name : record.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
@ -1,17 +1,272 @@
|
|||||||
import React from "react";
|
import React, { Fragment } from "react";
|
||||||
import { Datagrid, List, TextField, Pagination } from "react-admin";
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
BooleanField,
|
||||||
|
BulkDeleteWithConfirmButton,
|
||||||
|
Datagrid,
|
||||||
|
Filter,
|
||||||
|
List,
|
||||||
|
Pagination,
|
||||||
|
ReferenceField,
|
||||||
|
ReferenceManyField,
|
||||||
|
SearchInput,
|
||||||
|
SelectField,
|
||||||
|
Show,
|
||||||
|
Tab,
|
||||||
|
TabbedShowLayout,
|
||||||
|
TextField,
|
||||||
|
useTranslate,
|
||||||
|
} from "react-admin";
|
||||||
|
import get from "lodash/get";
|
||||||
|
import { Tooltip, Typography, Chip } from "@material-ui/core";
|
||||||
|
import HttpsIcon from "@material-ui/icons/Https";
|
||||||
|
import NoEncryptionIcon from "@material-ui/icons/NoEncryption";
|
||||||
|
import PageviewIcon from "@material-ui/icons/Pageview";
|
||||||
|
import UserIcon from "@material-ui/icons/Group";
|
||||||
|
import ViewListIcon from "@material-ui/icons/ViewList";
|
||||||
|
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||||
|
|
||||||
const RoomPagination = props => (
|
const RoomPagination = props => (
|
||||||
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RoomList = props => (
|
const EncryptionField = ({ source, record = {}, emptyText }) => {
|
||||||
<List {...props} pagination={<RoomPagination />}>
|
const translate = useTranslate();
|
||||||
<Datagrid>
|
const value = get(record, source);
|
||||||
<TextField source="room_id" />
|
let ariaLabel = value === false ? "ra.boolean.false" : "ra.boolean.true";
|
||||||
<TextField source="name" />
|
|
||||||
<TextField source="canonical_alias" />
|
if (value === false || value === true) {
|
||||||
<TextField source="joined_members" />
|
return (
|
||||||
</Datagrid>
|
<Typography component="span" variant="body2">
|
||||||
</List>
|
<Tooltip title={translate(ariaLabel, { _: ariaLabel })}>
|
||||||
|
{value === true ? (
|
||||||
|
<HttpsIcon data-testid="true" htmlColor="limegreen" />
|
||||||
|
) : (
|
||||||
|
<NoEncryptionIcon data-testid="false" color="error" />
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography component="span" variant="body2">
|
||||||
|
{emptyText}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoomTitle = ({ record }) => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
var name = "";
|
||||||
|
if (record) {
|
||||||
|
name = record.name !== "" ? record.name : record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{translate("resources.rooms.name", 1)} {name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoomShow = props => {
|
||||||
|
const translate = useTranslate();
|
||||||
|
return (
|
||||||
|
<Show {...props} title={<RoomTitle />}>
|
||||||
|
<TabbedShowLayout>
|
||||||
|
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
|
||||||
|
<TextField source="room_id" />
|
||||||
|
<TextField source="name" />
|
||||||
|
<TextField source="canonical_alias" />
|
||||||
|
<TextField source="creator" />
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab
|
||||||
|
label="synapseadmin.rooms.tabs.detail"
|
||||||
|
icon={<PageviewIcon />}
|
||||||
|
path="detail"
|
||||||
|
>
|
||||||
|
<TextField source="joined_members" />
|
||||||
|
<TextField source="joined_local_members" />
|
||||||
|
<TextField source="state_events" />
|
||||||
|
<TextField source="version" />
|
||||||
|
<TextField
|
||||||
|
source="encryption"
|
||||||
|
emptyText={translate("resources.rooms.enums.unencrypted")}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />}>
|
||||||
|
<ReferenceManyField
|
||||||
|
reference="room_members"
|
||||||
|
target="room_id"
|
||||||
|
addLabel={false}
|
||||||
|
>
|
||||||
|
<Datagrid
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
rowClick={(id, basePath, record) => "/users/" + id}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
source="id"
|
||||||
|
sortable={false}
|
||||||
|
label="resources.users.fields.id"
|
||||||
|
/>
|
||||||
|
<ReferenceField
|
||||||
|
label="resources.users.fields.displayname"
|
||||||
|
source="id"
|
||||||
|
reference="users"
|
||||||
|
sortable={false}
|
||||||
|
link=""
|
||||||
|
>
|
||||||
|
<TextField source="displayname" sortable={false} />
|
||||||
|
</ReferenceField>
|
||||||
|
</Datagrid>
|
||||||
|
</ReferenceManyField>
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab
|
||||||
|
label="synapseadmin.rooms.tabs.permission"
|
||||||
|
icon={<VisibilityIcon />}
|
||||||
|
path="permission"
|
||||||
|
>
|
||||||
|
<BooleanField source="federatable" />
|
||||||
|
<BooleanField source="public" />
|
||||||
|
<SelectField
|
||||||
|
source="join_rules"
|
||||||
|
choices={[
|
||||||
|
{ id: "public", name: "resources.rooms.enums.join_rules.public" },
|
||||||
|
{ id: "knock", name: "resources.rooms.enums.join_rules.knock" },
|
||||||
|
{ id: "invite", name: "resources.rooms.enums.join_rules.invite" },
|
||||||
|
{
|
||||||
|
id: "private",
|
||||||
|
name: "resources.rooms.enums.join_rules.private",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
source="guest_access"
|
||||||
|
choices={[
|
||||||
|
{
|
||||||
|
id: "can_join",
|
||||||
|
name: "resources.rooms.enums.guest_access.can_join",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "forbidden",
|
||||||
|
name: "resources.rooms.enums.guest_access.forbidden",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
source="history_visibility"
|
||||||
|
choices={[
|
||||||
|
{
|
||||||
|
id: "invited",
|
||||||
|
name: "resources.rooms.enums.history_visibility.invited",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "joined",
|
||||||
|
name: "resources.rooms.enums.history_visibility.joined",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shared",
|
||||||
|
name: "resources.rooms.enums.history_visibility.shared",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "world_readable",
|
||||||
|
name: "resources.rooms.enums.history_visibility.world_readable",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</TabbedShowLayout>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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"
|
||||||
|
defaultValue={false}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={translate("resources.rooms.fields.state_events")}
|
||||||
|
source="state_events"
|
||||||
|
defaultValue={false}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={translate("resources.rooms.fields.version")}
|
||||||
|
source="version"
|
||||||
|
defaultValue={false}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={translate("resources.rooms.fields.federatable")}
|
||||||
|
source="federatable"
|
||||||
|
defaultValue={false}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
</Filter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterableRoomList = ({ ...props }) => {
|
||||||
|
const filter = props.roomFilters;
|
||||||
|
const localMembersFilter =
|
||||||
|
filter && filter.joined_local_members ? true : false;
|
||||||
|
const stateEventsFilter = filter && filter.state_events ? true : false;
|
||||||
|
const versionFilter = filter && filter.version ? true : false;
|
||||||
|
const federateableFilter = filter && filter.federatable ? true : false;
|
||||||
|
const translate = useTranslate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
{...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
|
||||||
|
source="is_encrypted"
|
||||||
|
sortBy="encryption"
|
||||||
|
label={<HttpsIcon />}
|
||||||
|
/>
|
||||||
|
<TextField source="name" />
|
||||||
|
<TextField source="joined_members" />
|
||||||
|
{localMembersFilter && <TextField source="joined_local_members" />}
|
||||||
|
{stateEventsFilter && <TextField source="state_events" />}
|
||||||
|
{versionFilter && <TextField source="version" />}
|
||||||
|
{federateableFilter && <BooleanField source="federatable" />}
|
||||||
|
<BooleanField source="public" />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomList = connect(mapStateToProps)(FilterableRoomList);
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import React, { Fragment } from "react";
|
import React, { cloneElement, Fragment } from "react";
|
||||||
|
import Avatar from "@material-ui/core/Avatar";
|
||||||
import PersonPinIcon from "@material-ui/icons/PersonPin";
|
import 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 SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent";
|
||||||
import {
|
import {
|
||||||
ArrayInput,
|
ArrayInput,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
|
Button,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
DateField,
|
||||||
Create,
|
Create,
|
||||||
@ -17,20 +22,102 @@ import {
|
|||||||
FormTab,
|
FormTab,
|
||||||
BooleanField,
|
BooleanField,
|
||||||
BooleanInput,
|
BooleanInput,
|
||||||
ImageField,
|
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
TextField,
|
TextField,
|
||||||
TextInput,
|
TextInput,
|
||||||
ReferenceField,
|
ReferenceField,
|
||||||
|
ReferenceManyField,
|
||||||
|
SearchInput,
|
||||||
SelectInput,
|
SelectInput,
|
||||||
BulkDeleteButton,
|
BulkDeleteButton,
|
||||||
DeleteButton,
|
DeleteButton,
|
||||||
SaveButton,
|
SaveButton,
|
||||||
regex,
|
regex,
|
||||||
|
useRedirect,
|
||||||
useTranslate,
|
useTranslate,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
CreateButton,
|
||||||
|
ExportButton,
|
||||||
|
TopToolbar,
|
||||||
|
sanitizeListRestProps,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { ServerNoticeButton } from "./ServerNotices";
|
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",
|
||||||
|
width: "40px",
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
height: "120px",
|
||||||
|
width: "120px",
|
||||||
|
float: "right",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserListActions = ({
|
||||||
|
currentSort,
|
||||||
|
className,
|
||||||
|
resource,
|
||||||
|
filters,
|
||||||
|
displayedFilters,
|
||||||
|
exporter, // you can hide ExportButton if exporter = (null || false)
|
||||||
|
filterValues,
|
||||||
|
permanentFilter,
|
||||||
|
hasCreate, // you can hide CreateButton if hasCreate = false
|
||||||
|
basePath,
|
||||||
|
selectedIds,
|
||||||
|
onUnselectItems,
|
||||||
|
showFilter,
|
||||||
|
maxResults,
|
||||||
|
total,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
const UserPagination = props => (
|
const UserPagination = props => (
|
||||||
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
|
||||||
@ -38,6 +125,7 @@ const UserPagination = props => (
|
|||||||
|
|
||||||
const UserFilter = props => (
|
const UserFilter = props => (
|
||||||
<Filter {...props}>
|
<Filter {...props}>
|
||||||
|
<SearchInput source="name" alwaysOn />
|
||||||
<BooleanInput source="guests" alwaysOn />
|
<BooleanInput source="guests" alwaysOn />
|
||||||
<BooleanInput
|
<BooleanInput
|
||||||
label="resources.users.fields.show_deactivated"
|
label="resources.users.fields.show_deactivated"
|
||||||
@ -51,6 +139,7 @@ const UserBulkActionButtons = props => {
|
|||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
<ServerNoticeBulkButton {...props} />
|
||||||
<BulkDeleteButton
|
<BulkDeleteButton
|
||||||
{...props}
|
{...props}
|
||||||
label="resources.users.action.erase"
|
label="resources.users.action.erase"
|
||||||
@ -60,46 +149,84 @@ const UserBulkActionButtons = props => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserList = props => (
|
const AvatarField = ({ source, className, record = {} }) => (
|
||||||
<List
|
<Avatar src={record[source]} className={className} />
|
||||||
{...props}
|
|
||||||
filters={<UserFilter />}
|
|
||||||
filterDefaultValues={{ guests: true, deactivated: 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>
|
|
||||||
<BooleanField source="is_guest" sortable={false} />
|
|
||||||
<BooleanField source="admin" sortable={false} />
|
|
||||||
<BooleanField source="deactivated" sortable={false} />
|
|
||||||
</Datagrid>
|
|
||||||
</List>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const UserList = props => {
|
||||||
|
const classes = useStyles();
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
{...props}
|
||||||
|
filters={<UserFilter />}
|
||||||
|
filterDefaultValues={{ guests: true, deactivated: false }}
|
||||||
|
actions={<UserListActions maxResults={10000} />}
|
||||||
|
bulkActionButtons={<UserBulkActionButtons />}
|
||||||
|
pagination={<UserPagination />}
|
||||||
|
>
|
||||||
|
<Datagrid rowClick="edit">
|
||||||
|
<AvatarField
|
||||||
|
source="avatar_src"
|
||||||
|
sortable={false}
|
||||||
|
className={classes.small}
|
||||||
|
/>
|
||||||
|
<TextField source="id" sortable={false} />
|
||||||
|
<TextField source="displayname" sortable={false} />
|
||||||
|
<BooleanField source="is_guest" sortable={false} />
|
||||||
|
<BooleanField source="admin" sortable={false} />
|
||||||
|
<BooleanField source="deactivated" sortable={false} />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// https://matrix.org/docs/spec/appendices#user-identifiers
|
// https://matrix.org/docs/spec/appendices#user-identifiers
|
||||||
const validateUser = regex(
|
const validateUser = regex(
|
||||||
/^@[a-z0-9._=\-/]+:.*/,
|
/^@[a-z0-9._=\-/]+:.*/,
|
||||||
"synapseadmin.users.invalid_user_id"
|
"synapseadmin.users.invalid_user_id"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function generateRandomUser() {
|
||||||
|
const homeserver = localStorage.getItem("home_server");
|
||||||
|
const user_id =
|
||||||
|
"@" +
|
||||||
|
Array(8)
|
||||||
|
.fill("0123456789abcdefghijklmnopqrstuvwxyz")
|
||||||
|
.map(
|
||||||
|
x =>
|
||||||
|
x[
|
||||||
|
Math.floor(
|
||||||
|
(crypto.getRandomValues(new Uint32Array(1))[0] /
|
||||||
|
(0xffffffff + 1)) *
|
||||||
|
x.length
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.join("") +
|
||||||
|
":" +
|
||||||
|
homeserver;
|
||||||
|
|
||||||
|
const password = Array(20)
|
||||||
|
.fill(
|
||||||
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$"
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
x =>
|
||||||
|
x[
|
||||||
|
Math.floor(
|
||||||
|
(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1)) *
|
||||||
|
x.length
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user_id,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const UserEditToolbar = props => {
|
const UserEditToolbar = props => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
return (
|
return (
|
||||||
@ -141,49 +268,84 @@ const UserTitle = ({ record }) => {
|
|||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{translate("resources.users.name")}{" "}
|
{translate("resources.users.name", {
|
||||||
|
smart_count: 1,
|
||||||
|
})}{" "}
|
||||||
{record ? `"${record.displayname}"` : ""}
|
{record ? `"${record.displayname}"` : ""}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const UserEdit = props => (
|
export const UserEdit = props => {
|
||||||
<Edit {...props} title={<UserTitle />}>
|
const classes = useStyles();
|
||||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
const translate = useTranslate();
|
||||||
<FormTab label="resources.users.name" icon={<PersonPinIcon />}>
|
return (
|
||||||
<TextInput source="id" disabled />
|
<Edit {...props} title={<UserTitle />}>
|
||||||
<TextInput source="displayname" />
|
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||||
<PasswordInput source="password" autoComplete="new-password" />
|
<FormTab
|
||||||
<BooleanInput source="admin" />
|
label={translate("resources.users.name", { smart_count: 1 })}
|
||||||
<BooleanInput
|
icon={<PersonPinIcon />}
|
||||||
source="deactivated"
|
>
|
||||||
helperText="resources.users.helper.deactivate"
|
<AvatarField
|
||||||
/>
|
source="avatar_src"
|
||||||
<ArrayInput source="threepids">
|
sortable={false}
|
||||||
<SimpleFormIterator>
|
className={classes.large}
|
||||||
<SelectInput
|
/>
|
||||||
source="medium"
|
<TextInput source="id" disabled />
|
||||||
choices={[
|
<TextInput source="displayname" />
|
||||||
{ id: "email", name: "resources.users.email" },
|
<PasswordInput source="password" autoComplete="new-password" />
|
||||||
{ id: "msisdn", name: "resources.users.msisdn" },
|
<BooleanInput source="admin" />
|
||||||
]}
|
<BooleanInput
|
||||||
/>
|
source="deactivated"
|
||||||
<TextInput source="address" />
|
helperText="resources.users.helper.deactivate"
|
||||||
</SimpleFormIterator>
|
/>
|
||||||
</ArrayInput>
|
<DateField
|
||||||
</FormTab>
|
source="creation_ts_ms"
|
||||||
<FormTab
|
showTime
|
||||||
label="resources.connections.name"
|
options={{
|
||||||
icon={<SettingsInputComponentIcon />}
|
year: "numeric",
|
||||||
>
|
month: "2-digit",
|
||||||
<ReferenceField reference="connections" source="id" addLabel={false}>
|
day: "2-digit",
|
||||||
<ArrayField
|
hour: "2-digit",
|
||||||
source="devices[].sessions[0].connections"
|
minute: "2-digit",
|
||||||
label="resources.connections.name"
|
second: "2-digit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField source="consent_version" />
|
||||||
|
</FormTab>
|
||||||
|
<FormTab
|
||||||
|
label="resources.users.threepid"
|
||||||
|
icon={<ContactMailIcon />}
|
||||||
|
path="threepid"
|
||||||
|
>
|
||||||
|
<ArrayInput source="threepids">
|
||||||
|
<SimpleFormIterator>
|
||||||
|
<SelectInput
|
||||||
|
source="medium"
|
||||||
|
choices={[
|
||||||
|
{ id: "email", name: "resources.users.email" },
|
||||||
|
{ id: "msisdn", name: "resources.users.msisdn" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TextInput source="address" />
|
||||||
|
</SimpleFormIterator>
|
||||||
|
</ArrayInput>
|
||||||
|
</FormTab>
|
||||||
|
<FormTab
|
||||||
|
label={translate("resources.devices.name", { smart_count: 2 })}
|
||||||
|
icon={<DevicesIcon />}
|
||||||
|
path="devices"
|
||||||
|
>
|
||||||
|
<ReferenceManyField
|
||||||
|
reference="devices"
|
||||||
|
target="user_id"
|
||||||
|
addLabel={false}
|
||||||
>
|
>
|
||||||
<Datagrid style={{ width: "100%" }}>
|
<Datagrid style={{ width: "100%" }}>
|
||||||
<TextField source="ip" sortable={false} />
|
<TextField source="device_id" sortable={false} />
|
||||||
|
<TextField source="display_name" sortable={false} />
|
||||||
|
<TextField source="last_seen_ip" sortable={false} />
|
||||||
<DateField
|
<DateField
|
||||||
source="last_seen"
|
source="last_seen_ts"
|
||||||
showTime
|
showTime
|
||||||
options={{
|
options={{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@ -195,15 +357,50 @@ export const UserEdit = props => (
|
|||||||
}}
|
}}
|
||||||
sortable={false}
|
sortable={false}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<DeviceRemoveButton />
|
||||||
source="user_agent"
|
|
||||||
sortable={false}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
</Datagrid>
|
</Datagrid>
|
||||||
</ArrayField>
|
</ReferenceManyField>
|
||||||
</ReferenceField>
|
</FormTab>
|
||||||
</FormTab>
|
<FormTab
|
||||||
</TabbedForm>
|
label="resources.connections.name"
|
||||||
</Edit>
|
icon={<SettingsInputComponentIcon />}
|
||||||
);
|
path="connections"
|
||||||
|
>
|
||||||
|
<ReferenceField
|
||||||
|
reference="connections"
|
||||||
|
source="id"
|
||||||
|
addLabel={false}
|
||||||
|
link={false}
|
||||||
|
>
|
||||||
|
<ArrayField
|
||||||
|
source="devices[].sessions[0].connections"
|
||||||
|
label="resources.connections.name"
|
||||||
|
>
|
||||||
|
<Datagrid style={{ width: "100%" }}>
|
||||||
|
<TextField source="ip" sortable={false} />
|
||||||
|
<DateField
|
||||||
|
source="last_seen"
|
||||||
|
showTime
|
||||||
|
options={{
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}}
|
||||||
|
sortable={false}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
source="user_agent"
|
||||||
|
sortable={false}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Datagrid>
|
||||||
|
</ArrayField>
|
||||||
|
</ReferenceField>
|
||||||
|
</FormTab>
|
||||||
|
</TabbedForm>
|
||||||
|
</Edit>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
141
src/i18n/de.js
141
src/i18n/de.js
@ -6,6 +6,7 @@ export default {
|
|||||||
auth: {
|
auth: {
|
||||||
base_url: "Heimserver URL",
|
base_url: "Heimserver URL",
|
||||||
welcome: "Willkommen bei Synapse-admin",
|
welcome: "Willkommen bei Synapse-admin",
|
||||||
|
server_version: "Synapse Version",
|
||||||
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
|
username_error: "Bitte vollständigen Nutzernamen angeben: '@user:domain'",
|
||||||
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen",
|
||||||
url_error: "Keine gültige Matrix Server URL",
|
url_error: "Keine gültige Matrix Server URL",
|
||||||
@ -14,6 +15,88 @@ export default {
|
|||||||
invalid_user_id:
|
invalid_user_id:
|
||||||
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
|
"Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver",
|
||||||
},
|
},
|
||||||
|
rooms: {
|
||||||
|
details: "Raumdetails",
|
||||||
|
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!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
@ -21,12 +104,13 @@ export default {
|
|||||||
name: "Benutzer",
|
name: "Benutzer",
|
||||||
email: "E-Mail",
|
email: "E-Mail",
|
||||||
msisdn: "Telefon",
|
msisdn: "Telefon",
|
||||||
|
threepid: "E-Mail / Telefon",
|
||||||
fields: {
|
fields: {
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
id: "Benutzer-ID",
|
id: "Benutzer-ID",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
is_guest: "Gast",
|
is_guest: "Gast",
|
||||||
admin: "Admin",
|
admin: "Server Administrator",
|
||||||
deactivated: "Deaktiviert",
|
deactivated: "Deaktiviert",
|
||||||
guests: "Zeige Gäste",
|
guests: "Zeige Gäste",
|
||||||
show_deactivated: "Zeige deaktivierte Benutzer",
|
show_deactivated: "Zeige deaktivierte Benutzer",
|
||||||
@ -34,9 +118,12 @@ export default {
|
|||||||
displayname: "Anzeigename",
|
displayname: "Anzeigename",
|
||||||
password: "Passwort",
|
password: "Passwort",
|
||||||
avatar_url: "Avatar URL",
|
avatar_url: "Avatar URL",
|
||||||
|
avatar_src: "Avatar",
|
||||||
medium: "Medium",
|
medium: "Medium",
|
||||||
threepids: "3PIDs",
|
threepids: "3PIDs",
|
||||||
address: "Adresse",
|
address: "Adresse",
|
||||||
|
creation_ts_ms: "Zeitpunkt der Erstellung",
|
||||||
|
consent_version: "Zugestimmte Geschäftsbedingungen",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
|
deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.",
|
||||||
@ -53,6 +140,36 @@ export default {
|
|||||||
name: "Name",
|
name: "Name",
|
||||||
canonical_alias: "Alias",
|
canonical_alias: "Alias",
|
||||||
joined_members: "Mitglieder",
|
joined_members: "Mitglieder",
|
||||||
|
joined_local_members: "Lokale Mitglieder",
|
||||||
|
state_events: "Ereignisse",
|
||||||
|
version: "Version",
|
||||||
|
is_encrypted: "Verschlüsselt",
|
||||||
|
encryption: "Verschlüsselungs-Algorithmus",
|
||||||
|
federatable: "Föderierbar",
|
||||||
|
public: "Öffentlich",
|
||||||
|
creator: "Ersteller",
|
||||||
|
join_rules: "Beitrittsregeln",
|
||||||
|
guest_access: "Gastzugriff",
|
||||||
|
history_visibility: "Historie-Sichtbarkeit",
|
||||||
|
},
|
||||||
|
enums: {
|
||||||
|
join_rules: {
|
||||||
|
public: "Öffentlich",
|
||||||
|
knock: "Auf Anfrage",
|
||||||
|
invite: "Nur auf Einladung",
|
||||||
|
private: "Privat",
|
||||||
|
},
|
||||||
|
guest_access: {
|
||||||
|
can_join: "Gäste können beitreten",
|
||||||
|
forbidden: "Gäste können nicht beitreten",
|
||||||
|
},
|
||||||
|
history_visibility: {
|
||||||
|
invited: "Ab Einladung",
|
||||||
|
joined: "Ab Beitritt",
|
||||||
|
shared: "Ab Setzen der Einstellung",
|
||||||
|
world_readable: "Jeder",
|
||||||
|
},
|
||||||
|
unencrypted: "Nicht verschlüsselt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: {
|
connections: {
|
||||||
@ -63,6 +180,23 @@ export default {
|
|||||||
user_agent: "User Agent",
|
user_agent: "User Agent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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}",
|
||||||
|
content: 'Möchten Sie das Gerät "%{name}" wirklich entfernen?',
|
||||||
|
success: "Gerät erfolgreich entfernt.",
|
||||||
|
failure: "Beim Entfernen ist ein Fehler aufgetreten.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
servernotices: {
|
servernotices: {
|
||||||
name: "Serverbenachrichtigungen",
|
name: "Serverbenachrichtigungen",
|
||||||
send: "Servernachricht versenden",
|
send: "Servernachricht versenden",
|
||||||
@ -98,5 +232,10 @@ export default {
|
|||||||
...germanMessages.ra.notifiaction,
|
...germanMessages.ra.notifiaction,
|
||||||
logged_out: "Abgemeldet",
|
logged_out: "Abgemeldet",
|
||||||
},
|
},
|
||||||
|
page: {
|
||||||
|
...germanMessages.ra.page,
|
||||||
|
empty: "Keine Einträge vorhanden",
|
||||||
|
invite: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
134
src/i18n/en.js
134
src/i18n/en.js
@ -14,6 +14,87 @@ export default {
|
|||||||
invalid_user_id:
|
invalid_user_id:
|
||||||
"Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver",
|
"Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver",
|
||||||
},
|
},
|
||||||
|
rooms: {
|
||||||
|
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!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: {
|
resources: {
|
||||||
users: {
|
users: {
|
||||||
@ -21,12 +102,13 @@ export default {
|
|||||||
name: "User |||| Users",
|
name: "User |||| Users",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
msisdn: "Phone",
|
msisdn: "Phone",
|
||||||
|
threepid: "Email / Phone",
|
||||||
fields: {
|
fields: {
|
||||||
avatar: "Avatar",
|
avatar: "Avatar",
|
||||||
id: "User-ID",
|
id: "User-ID",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
is_guest: "Guest",
|
is_guest: "Guest",
|
||||||
admin: "Admin",
|
admin: "Server Administrator",
|
||||||
deactivated: "Deactivated",
|
deactivated: "Deactivated",
|
||||||
guests: "Show guests",
|
guests: "Show guests",
|
||||||
show_deactivated: "Show deactivated users",
|
show_deactivated: "Show deactivated users",
|
||||||
@ -34,9 +116,12 @@ export default {
|
|||||||
displayname: "Displayname",
|
displayname: "Displayname",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
avatar_url: "Avatar URL",
|
avatar_url: "Avatar URL",
|
||||||
|
avatar_src: "Avatar",
|
||||||
medium: "Medium",
|
medium: "Medium",
|
||||||
threepids: "3PIDs",
|
threepids: "3PIDs",
|
||||||
address: "Address",
|
address: "Address",
|
||||||
|
creation_ts_ms: "Creation timestamp",
|
||||||
|
consent_version: "Consent version",
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
deactivate: "Deactivated users cannot be reactivated",
|
deactivate: "Deactivated users cannot be reactivated",
|
||||||
@ -53,6 +138,36 @@ export default {
|
|||||||
name: "Name",
|
name: "Name",
|
||||||
canonical_alias: "Alias",
|
canonical_alias: "Alias",
|
||||||
joined_members: "Members",
|
joined_members: "Members",
|
||||||
|
joined_local_members: "local members",
|
||||||
|
state_events: "State events",
|
||||||
|
version: "Version",
|
||||||
|
is_encrypted: "Encrypted",
|
||||||
|
encryption: "Encryption",
|
||||||
|
federatable: "Federatable",
|
||||||
|
public: "Public",
|
||||||
|
creator: "Creator",
|
||||||
|
join_rules: "Join rules",
|
||||||
|
guest_access: "Guest access",
|
||||||
|
history_visibility: "History visibility",
|
||||||
|
},
|
||||||
|
enums: {
|
||||||
|
join_rules: {
|
||||||
|
public: "Public",
|
||||||
|
knock: "Knock",
|
||||||
|
invite: "Invite",
|
||||||
|
private: "Private",
|
||||||
|
},
|
||||||
|
guest_access: {
|
||||||
|
can_join: "Guests can join",
|
||||||
|
forbidden: "Guests can not join",
|
||||||
|
},
|
||||||
|
history_visibility: {
|
||||||
|
invited: "Since invited",
|
||||||
|
joined: "Since joined",
|
||||||
|
shared: "Since shared",
|
||||||
|
world_readable: "Anyone",
|
||||||
|
},
|
||||||
|
unencrypted: "Unencrypted",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: {
|
connections: {
|
||||||
@ -63,6 +178,23 @@ export default {
|
|||||||
user_agent: "User agent",
|
user_agent: "User agent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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}",
|
||||||
|
content: 'Are you sure you want to remove the device "%{name}"?',
|
||||||
|
success: "Device successfully removed.",
|
||||||
|
failure: "An error has occurred.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
servernotices: {
|
servernotices: {
|
||||||
name: "Server Notices",
|
name: "Server Notices",
|
||||||
send: "Send server notices",
|
send: "Send server notices",
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { configure } from "enzyme";
|
import { configure } from "enzyme";
|
||||||
import Adapter from "enzyme-adapter-react-16";
|
import Adapter from "enzyme-adapter-react-16";
|
||||||
|
import fetchMock from "jest-fetch-mock";
|
||||||
|
|
||||||
configure({ adapter: new Adapter() });
|
configure({ adapter: new Adapter() });
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
@ -14,29 +14,39 @@ const jsonClient = (url, options = {}) => {
|
|||||||
return fetchUtils.fetchJson(url, options);
|
return fetchUtils.fetchJson(url, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mxcUrlToHttp = mxcUrl => {
|
||||||
|
const homeserver = localStorage.getItem("base_url");
|
||||||
|
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
|
||||||
|
var ret = re.exec(mxcUrl);
|
||||||
|
console.log("mxcClient " + ret);
|
||||||
|
if (ret == null) return null;
|
||||||
|
const serverName = ret[1];
|
||||||
|
const mediaId = ret[2];
|
||||||
|
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
|
||||||
|
};
|
||||||
|
|
||||||
const resourceMap = {
|
const resourceMap = {
|
||||||
users: {
|
users: {
|
||||||
path: "/_synapse/admin/v2/users",
|
path: "/_synapse/admin/v2/users",
|
||||||
map: u => ({
|
map: u => ({
|
||||||
...u,
|
...u,
|
||||||
id: u.name,
|
id: u.name,
|
||||||
|
avatar_src: mxcUrlToHttp(u.avatar_url),
|
||||||
is_guest: !!u.is_guest,
|
is_guest: !!u.is_guest,
|
||||||
admin: !!u.admin,
|
admin: !!u.admin,
|
||||||
deactivated: !!u.deactivated,
|
deactivated: !!u.deactivated,
|
||||||
|
// need timestamp in milliseconds
|
||||||
|
creation_ts_ms: u.creation_ts * 1000,
|
||||||
}),
|
}),
|
||||||
data: "users",
|
data: "users",
|
||||||
total: (json, from, perPage) => {
|
total: json => json.total,
|
||||||
return json.next_token
|
|
||||||
? parseInt(json.next_token, 10) + perPage
|
|
||||||
: from + json.users.length;
|
|
||||||
},
|
|
||||||
create: data => ({
|
create: data => ({
|
||||||
endpoint: `/_synapse/admin/v2/users/${data.id}`,
|
endpoint: `/_synapse/admin/v2/users/${data.id}`,
|
||||||
body: data,
|
body: data,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
}),
|
}),
|
||||||
delete: id => ({
|
delete: params => ({
|
||||||
endpoint: `/_synapse/admin/v1/deactivate/${id}`,
|
endpoint: `/_synapse/admin/v1/deactivate/${params.id}`,
|
||||||
body: { erase: true },
|
body: { erase: true },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
@ -48,11 +58,33 @@ const resourceMap = {
|
|||||||
id: r.room_id,
|
id: r.room_id,
|
||||||
alias: r.canonical_alias,
|
alias: r.canonical_alias,
|
||||||
members: r.joined_members,
|
members: r.joined_members,
|
||||||
|
is_encrypted: !!r.encryption,
|
||||||
|
federatable: !!r.federatable,
|
||||||
|
public: !!r.public,
|
||||||
}),
|
}),
|
||||||
data: "rooms",
|
data: "rooms",
|
||||||
total: json => {
|
total: json => {
|
||||||
return json.total_rooms;
|
return json.total_rooms;
|
||||||
},
|
},
|
||||||
|
delete: params => ({
|
||||||
|
endpoint: `/_synapse/admin/v1/rooms/${params.id}/delete`,
|
||||||
|
body: { block: false },
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
map: d => ({
|
||||||
|
...d,
|
||||||
|
id: d.device_id,
|
||||||
|
}),
|
||||||
|
data: "devices",
|
||||||
|
total: json => json.devices.length,
|
||||||
|
reference: id => ({
|
||||||
|
endpoint: `/_synapse/admin/v2/users/${id}/devices`,
|
||||||
|
}),
|
||||||
|
delete: params => ({
|
||||||
|
endpoint: `/_synapse/admin/v2/users/${params.user_id}/devices/${params.id}`,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
connections: {
|
connections: {
|
||||||
path: "/_synapse/admin/v1/whois",
|
path: "/_synapse/admin/v1/whois",
|
||||||
@ -62,6 +94,16 @@ const resourceMap = {
|
|||||||
}),
|
}),
|
||||||
data: "connections",
|
data: "connections",
|
||||||
},
|
},
|
||||||
|
room_members: {
|
||||||
|
map: m => ({
|
||||||
|
id: m,
|
||||||
|
}),
|
||||||
|
reference: id => ({
|
||||||
|
endpoint: `/_synapse/admin/v1/rooms/${id}/members`,
|
||||||
|
}),
|
||||||
|
data: "members",
|
||||||
|
total: json => json.members.length,
|
||||||
|
},
|
||||||
servernotices: {
|
servernotices: {
|
||||||
map: n => ({ id: n.event_id }),
|
map: n => ({ id: n.event_id }),
|
||||||
create: data => ({
|
create: data => ({
|
||||||
@ -86,18 +128,31 @@ function filterNullValues(key, value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchOrder(order) {
|
||||||
|
if (order === "DESC") {
|
||||||
|
return "b";
|
||||||
|
} else {
|
||||||
|
return "f";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dataProvider = {
|
const dataProvider = {
|
||||||
getList: (resource, params) => {
|
getList: (resource, params) => {
|
||||||
console.log("getList " + resource);
|
console.log("getList " + resource);
|
||||||
const { user_id, guests, deactivated } = params.filter;
|
const { user_id, name, guests, deactivated, search_term } = params.filter;
|
||||||
const { page, perPage } = params.pagination;
|
const { page, perPage } = params.pagination;
|
||||||
|
const { field, order } = params.sort;
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
const query = {
|
const query = {
|
||||||
from: from,
|
from: from,
|
||||||
limit: perPage,
|
limit: perPage,
|
||||||
user_id: user_id,
|
user_id: user_id,
|
||||||
|
search_term: search_term,
|
||||||
|
name: name,
|
||||||
guests: guests,
|
guests: guests,
|
||||||
deactivated: deactivated,
|
deactivated: deactivated,
|
||||||
|
order_by: field,
|
||||||
|
dir: getSearchOrder(order),
|
||||||
};
|
};
|
||||||
const homeserver = localStorage.getItem("base_url");
|
const homeserver = localStorage.getItem("base_url");
|
||||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||||
@ -138,34 +193,26 @@ const dataProvider = {
|
|||||||
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`))
|
params.ids.map(id => jsonClient(`${endpoint_url}/${id}`))
|
||||||
).then(responses => ({
|
).then(responses => ({
|
||||||
data: responses.map(({ json }) => res.map(json)),
|
data: responses.map(({ json }) => res.map(json)),
|
||||||
|
total: responses.length,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
getManyReference: (resource, params) => {
|
getManyReference: (resource, params) => {
|
||||||
// FIXME
|
|
||||||
console.log("getManyReference " + resource);
|
console.log("getManyReference " + resource);
|
||||||
const { page, perPage } = params.pagination;
|
const { page, perPage } = params.pagination;
|
||||||
const { field, order } = params.sort;
|
const from = (page - 1) * perPage;
|
||||||
const query = {
|
|
||||||
sort: JSON.stringify([field, order]),
|
|
||||||
range: JSON.stringify([(page - 1) * perPage, page * perPage - 1]),
|
|
||||||
filter: JSON.stringify({
|
|
||||||
...params.filter,
|
|
||||||
[params.target]: params.id,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const homeserver = localStorage.getItem("base_url");
|
const homeserver = localStorage.getItem("base_url");
|
||||||
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||||
|
|
||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
const endpoint_url = homeserver + res.path;
|
const ref = res["reference"](params.id);
|
||||||
const url = `${endpoint_url}?${stringify(query)}`;
|
const endpoint_url = homeserver + ref.endpoint;
|
||||||
|
|
||||||
return jsonClient(url).then(({ headers, json }) => ({
|
return jsonClient(endpoint_url).then(({ headers, json }) => ({
|
||||||
data: json,
|
data: json[res.data].map(res.map),
|
||||||
total: parseInt(headers.get("content-range").split("/").pop(), 10),
|
total: res.total(json, from, perPage),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -221,6 +268,29 @@ const dataProvider = {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createMany: (resource, params) => {
|
||||||
|
console.log("createMany " + resource);
|
||||||
|
const homeserver = localStorage.getItem("base_url");
|
||||||
|
if (!homeserver || !(resource in resourceMap)) return Promise.reject();
|
||||||
|
|
||||||
|
const res = resourceMap[resource];
|
||||||
|
if (!("create" in res)) return Promise.reject();
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
params.ids.map(id => {
|
||||||
|
params.data.id = id;
|
||||||
|
const cre = res["create"](params.data);
|
||||||
|
const endpoint_url = homeserver + cre.endpoint;
|
||||||
|
return jsonClient(endpoint_url, {
|
||||||
|
method: cre.method,
|
||||||
|
body: JSON.stringify(cre.body, filterNullValues),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
).then(responses => ({
|
||||||
|
data: responses.map(({ json }) => json),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
delete: (resource, params) => {
|
delete: (resource, params) => {
|
||||||
console.log("delete " + resource);
|
console.log("delete " + resource);
|
||||||
const homeserver = localStorage.getItem("base_url");
|
const homeserver = localStorage.getItem("base_url");
|
||||||
@ -229,11 +299,11 @@ const dataProvider = {
|
|||||||
const res = resourceMap[resource];
|
const res = resourceMap[resource];
|
||||||
|
|
||||||
if ("delete" in res) {
|
if ("delete" in res) {
|
||||||
const del = res["delete"](params.id);
|
const del = res["delete"](params);
|
||||||
const endpoint_url = homeserver + del.endpoint;
|
const endpoint_url = homeserver + del.endpoint;
|
||||||
return jsonClient(endpoint_url, {
|
return jsonClient(endpoint_url, {
|
||||||
method: del.method,
|
method: "method" in del ? del.method : "DELETE",
|
||||||
body: JSON.stringify(del.body),
|
body: "body" in del ? JSON.stringify(del.body) : null,
|
||||||
}).then(({ json }) => ({
|
}).then(({ json }) => ({
|
||||||
data: json,
|
data: json,
|
||||||
}));
|
}));
|
||||||
@ -258,11 +328,11 @@ const dataProvider = {
|
|||||||
if ("delete" in res) {
|
if ("delete" in res) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
params.ids.map(id => {
|
params.ids.map(id => {
|
||||||
const del = res["delete"](id);
|
const del = res["delete"]({ ...params, id: id });
|
||||||
const endpoint_url = homeserver + del.endpoint;
|
const endpoint_url = homeserver + del.endpoint;
|
||||||
return jsonClient(endpoint_url, {
|
return jsonClient(endpoint_url, {
|
||||||
method: del.method,
|
method: "method" in del ? del.method : "DELETE",
|
||||||
body: JSON.stringify(del.body),
|
body: "body" in del ? JSON.stringify(del.body) : null,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
).then(responses => ({
|
).then(responses => ({
|
||||||
|
78
src/synapse/dataProvider.test.js
Normal file
78
src/synapse/dataProvider.test.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import dataProvider from "./dataProvider";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dataProvider", () => {
|
||||||
|
localStorage.setItem("base_url", "http://localhost");
|
||||||
|
localStorage.setItem("access_token", "access_token");
|
||||||
|
|
||||||
|
it("fetches all users", async () => {
|
||||||
|
fetch.mockResponseOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
name: "user_id1",
|
||||||
|
password_hash: "password_hash1",
|
||||||
|
is_guest: 0,
|
||||||
|
admin: 0,
|
||||||
|
user_type: null,
|
||||||
|
deactivated: 0,
|
||||||
|
displayname: "User One",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_id2",
|
||||||
|
password_hash: "password_hash2",
|
||||||
|
is_guest: 0,
|
||||||
|
admin: 1,
|
||||||
|
user_type: null,
|
||||||
|
deactivated: 0,
|
||||||
|
displayname: "User Two",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next_token: "100",
|
||||||
|
total: 200,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = await dataProvider.getList("users", {
|
||||||
|
pagination: { page: 1, perPage: 5 },
|
||||||
|
sort: { field: "title", order: "ASC" },
|
||||||
|
filter: { author_id: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(users["data"][0]["id"]).toEqual("user_id1");
|
||||||
|
expect(users["total"]).toEqual(200);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches one user", async () => {
|
||||||
|
fetch.mockResponseOnce(
|
||||||
|
JSON.stringify({
|
||||||
|
name: "user_id1",
|
||||||
|
password: "user_password",
|
||||||
|
displayname: "User",
|
||||||
|
threepids: [
|
||||||
|
{
|
||||||
|
medium: "email",
|
||||||
|
address: "user@mail_1.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
medium: "email",
|
||||||
|
address: "user@mail_2.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
avatar_url: "mxc://localhost/user1",
|
||||||
|
admin: false,
|
||||||
|
deactivated: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await dataProvider.getOne("users", { id: "user_id1" });
|
||||||
|
|
||||||
|
expect(user["data"]["id"]).toEqual("user_id1");
|
||||||
|
expect(user["data"]["displayname"]).toEqual("User");
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user