diff --git a/.dockerignore b/.dockerignore index b2a9091..a9f7bc4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ # Exclude a bunch of stuff which can make the build context a larger than it needs to be -.git/ tests/ build/ lib/ diff --git a/README.md b/README.md index 4bae8a4..6af447f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This project is built using [react-admin](https://marmelab.com/react-admin/). -It needs at least Synapse v1.14.0 for all functions to work as expected! +It needs at least Synapse v1.15.0 for all functions to work as expected! ## Step-By-Step install: @@ -29,3 +29,8 @@ Steps for 2): ## Screenshots ![Screenshots](./screenshots.jpg) + +## Development + +- Use `yarn test` to run all style, lint and unit tests +- Use `yarn fix` to fix the coding style diff --git a/package.json b/package.json index 2bb3cd4..09db13f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "eslint": "^6.8.0", "eslint-config-prettier": "^6.10.1", "eslint-plugin-prettier": "^3.1.2", + "jest-fetch-mock": "^3.0.3", "prettier": "^2.0.0" }, "dependencies": { diff --git a/src/App.js b/src/App.js index b3c40c3..a7fc125 100644 --- a/src/App.js +++ b/src/App.js @@ -37,6 +37,7 @@ const App = () => ( /> + ); diff --git a/src/components/devices.js b/src/components/devices.js new file mode 100644 index 0000000..ee9292c --- /dev/null +++ b/src/components/devices.js @@ -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 ( + + + + + ); +}; diff --git a/src/components/users.js b/src/components/users.js index c65cd12..a6889b2 100644 --- a/src/components/users.js +++ b/src/components/users.js @@ -2,6 +2,7 @@ import React, { cloneElement, Fragment } from "react"; import Avatar from "@material-ui/core/Avatar"; import PersonPinIcon from "@material-ui/icons/PersonPin"; import ContactMailIcon from "@material-ui/icons/ContactMail"; +import DevicesIcon from "@material-ui/icons/Devices"; import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent"; import { ArrayInput, @@ -23,6 +24,7 @@ import { TextField, TextInput, ReferenceField, + ReferenceManyField, SelectInput, BulkDeleteButton, DeleteButton, @@ -36,6 +38,7 @@ import { sanitizeListRestProps, } from "react-admin"; import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices"; +import { DeviceRemoveButton } from "./devices"; import { makeStyles } from "@material-ui/core/styles"; const useStyles = makeStyles({ @@ -205,6 +208,7 @@ const UserTitle = ({ record }) => { }; export const UserEdit = props => { const classes = useStyles(); + const translate = useTranslate(); return ( }> }> @@ -254,6 +258,37 @@ export const UserEdit = props => { + } + path="devices" + > + + + + + + + + + + } diff --git a/src/i18n/de.js b/src/i18n/de.js index 560e310..0243041 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -37,7 +37,7 @@ export default { id: "Benutzer-ID", name: "Name", is_guest: "Gast", - admin: "Admin", + admin: "Server Administrator", deactivated: "Deaktiviert", guests: "Zeige Gäste", show_deactivated: "Zeige deaktivierte Benutzer", @@ -51,6 +51,11 @@ export default { address: "Adresse", creation_ts_ms: "Zeitpunkt der Erstellung", consent_version: "Zugestimmte Geschäftsbedingungen", + // Devices: + device_id: "Geräte-ID", + display_name: "Gerätename", + last_seen_ts: "Zeitstempel", + last_seen_ip: "IP-Adresse", }, helper: { deactivate: "Deaktivierte Nutzer können nicht wieder aktiviert werden.", @@ -107,6 +112,17 @@ export default { user_agent: "User Agent", }, }, + devices: { + name: "Gerät |||| Geräte", + action: { + erase: { + title: "Entferne %{id}", + content: 'Möchten Sie das Gerät "%{name}" wirklich entfernen?', + success: "Gerät erfolgreich entfernt.", + failure: "Beim Entfernen ist ein Fehler aufgetreten.", + }, + }, + }, servernotices: { name: "Serverbenachrichtigungen", send: "Servernachricht versenden", diff --git a/src/i18n/en.js b/src/i18n/en.js index 12ba1ae..f6501c9 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -35,7 +35,7 @@ export default { id: "User-ID", name: "Name", is_guest: "Guest", - admin: "Admin", + admin: "Server Administrator", deactivated: "Deactivated", guests: "Show guests", show_deactivated: "Show deactivated users", @@ -49,6 +49,11 @@ export default { address: "Address", creation_ts_ms: "Creation timestamp", consent_version: "Consent version", + // Devices: + device_id: "Device-ID", + display_name: "Device name", + last_seen_ts: "Timestamp", + last_seen_ip: "IP address", }, helper: { deactivate: "Deactivated users cannot be reactivated", @@ -105,6 +110,17 @@ export default { user_agent: "User agent", }, }, + devices: { + name: "Device |||| Devices", + action: { + erase: { + title: "Removing %{id}", + content: 'Are you sure you want to remove the device "%{name}"?', + success: "Device successfully removed.", + failure: "An error has occurred.", + }, + }, + }, servernotices: { name: "Server Notices", send: "Send server notices", diff --git a/src/setupTests.js b/src/setupTests.js index 6f413a4..b1a6b6d 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,4 +1,6 @@ import { configure } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; +import fetchMock from "jest-fetch-mock"; configure({ adapter: new Adapter() }); +fetchMock.enableMocks(); diff --git a/src/synapse/dataProvider.js b/src/synapse/dataProvider.js index cebc413..d4c8bcd 100644 --- a/src/synapse/dataProvider.js +++ b/src/synapse/dataProvider.js @@ -45,8 +45,8 @@ const resourceMap = { body: data, method: "PUT", }), - delete: id => ({ - endpoint: `/_synapse/admin/v1/deactivate/${id}`, + delete: params => ({ + endpoint: `/_synapse/admin/v1/deactivate/${params.id}`, body: { erase: true }, method: "POST", }), @@ -67,6 +67,19 @@ const resourceMap = { return json.total_rooms; }, }, + devices: { + map: d => ({ + ...d, + id: d.device_id, + }), + data: "devices", + reference: id => ({ + endpoint: `/_synapse/admin/v2/users/${id}/devices`, + }), + delete: params => ({ + endpoint: `/_synapse/admin/v2/users/${params.user_id}/devices/${params.id}`, + }), + }, connections: { path: "/_synapse/admin/v1/whois", map: c => ({ @@ -166,30 +179,18 @@ const dataProvider = { }, getManyReference: (resource, params) => { - // FIXME console.log("getManyReference " + resource); - const { page, perPage } = params.pagination; - const { field, order } = params.sort; - const query = { - sort: JSON.stringify([field, order]), - range: JSON.stringify([(page - 1) * perPage, page * perPage - 1]), - filter: JSON.stringify({ - ...params.filter, - [params.target]: params.id, - }), - }; const homeserver = localStorage.getItem("base_url"); if (!homeserver || !(resource in resourceMap)) return Promise.reject(); const res = resourceMap[resource]; - const endpoint_url = homeserver + res.path; - const url = `${endpoint_url}?${stringify(query)}`; + const ref = res["reference"](params.id); + const endpoint_url = homeserver + ref.endpoint; - return jsonClient(url).then(({ headers, json }) => ({ - data: json, - total: parseInt(headers.get("content-range").split("/").pop(), 10), + return jsonClient(endpoint_url).then(({ headers, json }) => ({ + data: json[res.data].map(res.map), })); }, @@ -276,11 +277,11 @@ const dataProvider = { const res = resourceMap[resource]; if ("delete" in res) { - const del = res["delete"](params.id); + const del = res["delete"](params); const endpoint_url = homeserver + del.endpoint; return jsonClient(endpoint_url, { - method: del.method, - body: JSON.stringify(del.body), + method: "method" in del ? del.method : "DELETE", + body: "body" in del ? JSON.stringify(del.body) : null, }).then(({ json }) => ({ data: json, })); @@ -305,11 +306,11 @@ const dataProvider = { if ("delete" in res) { return Promise.all( params.ids.map(id => { - const del = res["delete"](id); + const del = res["delete"]({ ...params, id: id }); const endpoint_url = homeserver + del.endpoint; return jsonClient(endpoint_url, { - method: del.method, - body: JSON.stringify(del.body), + method: "method" in del ? del.method : "DELETE", + body: "body" in del ? JSON.stringify(del.body) : null, }); }) ).then(responses => ({ diff --git a/src/synapse/dataProvider.test.js b/src/synapse/dataProvider.test.js new file mode 100644 index 0000000..a6563b7 --- /dev/null +++ b/src/synapse/dataProvider.test.js @@ -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); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7607589..b20f394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3502,6 +3502,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +cross-fetch@^3.0.4: + version "3.0.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.5.tgz#2739d2981892e7ab488a7ad03b92df2816e03f4c" + integrity sha512-FFLcLtraisj5eteosnX1gf01qYDCOc4fDy0+euOt8Kn9YBY2NtXL/pCoYPavw24NIQkQqm5ZOLsGD5Zzj0gyew== + dependencies: + node-fetch "2.6.0" + cross-spawn@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" @@ -6447,6 +6454,14 @@ jest-environment-node@^24.9.0: jest-mock "^24.9.0" jest-util "^24.9.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" @@ -7650,6 +7665,11 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -9131,6 +9151,11 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise-polyfill@^8.1.3: + version "8.1.3" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" + integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g== + promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"