diff --git a/package.json b/package.json index 2bb3cd4..61b0303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "synapse-admin", - "version": "0.2.1", + "version": "AMP/2020.07", "description": "Admin GUI for the Matrix.org server Synapse", "author": "Awesome Technologies Innovationslabor GmbH", "license": "Apache-2.0", @@ -21,7 +21,11 @@ "prettier": "^2.0.0" }, "dependencies": { + "@progress/kendo-drawing": "^1.6.0", + "@progress/kendo-react-pdf": "^3.10.1", + "babel-preset-jest": "^24.9.0", "prop-types": "^15.7.2", + "qrcode.react": "^1.0.0", "ra-language-german": "^2.1.2", "react": "^16.13.1", "react-admin": "^3.7.0", diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..d16a65a Binary files /dev/null and b/public/images/logo.png differ diff --git a/src/App.js b/src/App.js index b3c40c3..c8cc6aa 100644 --- a/src/App.js +++ b/src/App.js @@ -4,12 +4,14 @@ import polyglotI18nProvider from "ra-i18n-polyglot"; import authProvider from "./synapse/authProvider"; import dataProvider from "./synapse/dataProvider"; import { UserList, UserCreate, UserEdit } from "./components/users"; -import { RoomList, RoomShow } from "./components/rooms"; +import { RoomList, RoomCreate, RoomShow } from "./components/rooms"; import LoginPage from "./components/LoginPage"; import UserIcon from "@material-ui/icons/Group"; import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList"; import germanMessages from "./i18n/de"; import englishMessages from "./i18n/en"; +import ShowUserPdf from "./components/ShowUserPdf"; +import { Route } from "react-router-dom"; // TODO: Can we use lazy loading together with browser locale? const messages = { @@ -27,6 +29,9 @@ const App = () => ( authProvider={authProvider} dataProvider={dataProvider} i18nProvider={i18nProvider} + customRoutes={[ + , + ]} > ( edit={UserEdit} icon={UserIcon} /> - + diff --git a/src/components/SaveQrButton.js b/src/components/SaveQrButton.js new file mode 100644 index 0000000..39fd0ba --- /dev/null +++ b/src/components/SaveQrButton.js @@ -0,0 +1,35 @@ +import React, { useCallback } from "react"; +import { SaveButton, useCreate, useRedirect, useNotify } from "react-admin"; + +const SaveQrButton = props => { + const [create] = useCreate("users"); + const redirectTo = useRedirect(); + const notify = useNotify(); + const { basePath } = props; + + const handleSave = useCallback( + (values, redirect) => { + create( + { + payload: { data: { ...values } }, + }, + { + onSuccess: ({ data: newRecord }) => { + notify("ra.notification.created", "info", { + smart_count: 1, + }); + redirectTo(redirect, basePath, newRecord.id, { + password: values.password, + ...newRecord, + }); + }, + } + ); + }, + [create, notify, redirectTo, basePath] + ); + + return ; +}; + +export default SaveQrButton; diff --git a/src/components/ShowUserPdf.js b/src/components/ShowUserPdf.js new file mode 100644 index 0000000..c235199 --- /dev/null +++ b/src/components/ShowUserPdf.js @@ -0,0 +1,132 @@ +import React from "react"; +import { Title, Button } from "react-admin"; +import { makeStyles } from "@material-ui/core/styles"; +import { PDFExport } from "@progress/kendo-react-pdf"; +import QRCode from "qrcode.react"; + +function xor(a, b) { + var res = ""; + for (var i = 0; i < a.length; i++) { + res += String.fromCharCode(a.charCodeAt(i) ^ b.charCodeAt(i % b.length)); + } + return res; +} + +function calculateQrString(serverUrl, username, password) { + const magicString = "wo9k5tep252qxsa5yde7366kugy6c01w7oeeya9hrmpf0t7ii7"; + var urlString = "user=" + username + "&password=" + password; + + urlString = xor(urlString, magicString); // xor with magic string + urlString = btoa(urlString); // to base64 + + return serverUrl + "/#" + urlString; +} + +const ShowUserPdf = props => { + const useStyles = makeStyles(theme => ({ + page: { + height: 800, + width: 566, + padding: "none", + backgroundColor: "white", + boxShadow: "5px 5px 5px black", + margin: "auto", + overflowX: "hidden", + overflowY: "hidden", + }, + header: { + height: 144, + width: 534, + marginLeft: 32, + marginTop: 15, + }, + name: { + width: 233, + fontSize: 40, + float: "left", + marginTop: 15, + }, + logo: { + width: 90, + marginTop: 20, + marginRight: 32, + float: "left", + }, + code: { + marginLeft: 330, + marginTop: 86, + }, + qr: { + marginRight: 40, + float: "right", + }, + note: { + fontSize: 18, + marginTop: 100, + marginLeft: 32, + marginRight: 32, + }, + })); + + const classes = useStyles(); + + var resume; + + const exportPDF = () => { + resume.save(); + }; + + var qrCode = ""; + var displayname = ""; + + if ( + props.location.state && + props.location.state.id && + props.location.state.password + ) { + const { id, password } = props.location.state; + + const username = id.substring(1, id.indexOf(":")); + const serverUrl = "https://" + id.substring(id.indexOf(":") + 1); + + const qrString = calculateQrString(serverUrl, username, password); + + qrCode = ; + displayname = props.location.state.displayname; + } + + return ( + + + + + (resume = r)} + > + + Ihr persönlicher Anmeldecode: + + {displayname} + + {qrCode} + + + Hier können Sie Ihre selbst gewählte Schlüsselsicherungs-Passphrase + notieren: + + + + + + + + + ); +}; + +export default ShowUserPdf; diff --git a/src/components/rooms.js b/src/components/rooms.js index 4a8a4c9..ff9d543 100644 --- a/src/components/rooms.js +++ b/src/components/rooms.js @@ -1,16 +1,23 @@ import React from "react"; import { connect } from "react-redux"; import { + AutocompleteArrayInput, + BooleanInput, BooleanField, + Create, Datagrid, Filter, + FormTab, List, Pagination, + ReferenceArrayInput, SelectField, Show, Tab, + TabbedForm, TabbedShowLayout, TextField, + TextInput, useTranslate, } from "react-admin"; import get from "lodash/get"; @@ -18,6 +25,7 @@ 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"; @@ -51,16 +59,117 @@ const EncryptionField = ({ source, record = {}, emptyText }) => { ); }; -const RoomTitle = ({ record }) => { - const translate = useTranslate(); - var name = ""; - if (record) { - name = record.name !== "" ? record.name : record.id; +const validateDisplayName = fieldval => + fieldval === undefined + ? "synapseadmin.rooms.room_name_required" + : fieldval.length === 0 + ? "synapseadmin.rooms.room_name_required" + : undefined; + +function approximateAliasLength(alias, homeserver) { + /* TODO maybe handle punycode in homeserver name */ + + var te; + + // Support for TextEncoder is quite widespread, but the polyfill is + // pretty large; We will only underestimate the size with the regular + // length attribute of String, so we never prevent the user from using + // an alias that is short enough for the server, but too long for our + // heuristic. + try { + te = new TextEncoder(); + } catch (err) { + if (err instanceof ReferenceError) { + te = undefined; + } } + const aliasLength = te === undefined ? alias.length : te.encode(alias).length; + + return "#".length + aliasLength + ":".length + homeserver.length; +} + +const validateAlias = fieldval => { + if (fieldval === undefined) { + return undefined; + } + const homeserver = localStorage.getItem("home_server"); + + if (approximateAliasLength(fieldval, homeserver) > 255) { + return "synapseadmin.rooms.alias_too_long"; + } +}; + +const removeLeadingWhitespace = fieldVal => + fieldVal === undefined ? undefined : fieldVal.trimStart(); +const replaceAllWhitespace = fieldVal => + fieldVal === undefined ? undefined : fieldVal.replace(/\s/, "_"); +const removeLeadingSigil = fieldVal => + fieldVal === undefined + ? undefined + : fieldVal.startsWith("#") + ? fieldVal.substr(1) + : fieldVal; + +const validateHasAliasIfPublic = formdata => { + let errors = {}; + if (formdata.public) { + if ( + formdata.canonical_alias === undefined || + formdata.canonical_alias.trim().length === 0 + ) { + errors.canonical_alias = "synapseadmin.rooms.alias_required_if_public"; + } + } + return errors; +}; + +export const RoomCreate = props => ( + + + }> + + replaceAllWhitespace(removeLeadingSigil(fv))} + validate={validateAlias} + placeholder="#" + /> + + + + } + > + ({ user_id: searchText })} + > + + + + + +); + +const RoomTitle = ({ record }) => { + const translate = useTranslate(); return ( - {translate("resources.rooms.name", 1)} {name} + {translate("resources.rooms.name", 1)} {record ? `"${record.name}"` : ""} ); }; diff --git a/src/components/users.js b/src/components/users.js index c65cd12..405635a 100644 --- a/src/components/users.js +++ b/src/components/users.js @@ -22,6 +22,7 @@ import { PasswordInput, TextField, TextInput, + SearchInput, ReferenceField, SelectInput, BulkDeleteButton, @@ -35,6 +36,7 @@ import { TopToolbar, sanitizeListRestProps, } from "react-admin"; +import SaveQrButton from "./SaveQrButton"; import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices"; import { makeStyles } from "@material-ui/core/styles"; @@ -95,6 +97,7 @@ const UserPagination = props => ( const UserFilter = props => ( + { className={classes.small} /> - + @@ -149,6 +152,75 @@ export const UserList = props => { ); }; +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, + }; +} + +// redirect to the related Author show page +const redirect = (basePath, id, data) => { + return { + pathname: "/showpdf", + state: { + id: data.id, + displayname: data.displayname, + password: data.password, + }, + }; +}; + +const UserCreateToolbar = props => ( + + + + +); + // https://matrix.org/docs/spec/appendices#user-identifiers const validateUser = regex( /^@[a-z0-9._=\-/]+:.*/, @@ -159,7 +231,17 @@ const UserEditToolbar = props => { const translate = useTranslate(); return ( - + + { }; export const UserCreate = props => ( - - + + }> @@ -203,6 +285,7 @@ const UserTitle = ({ record }) => { ); }; + export const UserEdit = props => { const classes = useStyles(); return ( diff --git a/src/i18n/de.js b/src/i18n/de.js index 560e310..bee641b 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -11,12 +11,25 @@ export default { protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen", url_error: "Keine gültige Matrix Server URL", }, + action: { + save_and_show: "Speichern und QR Code erzeugen", + save_only: "Nur speichern", + download_pdf: "PDF speichern", + }, users: { invalid_user_id: "Muss eine vollständige Matrix Benutzer-ID sein, z.B. @benutzer_id:homeserver", }, rooms: { details: "Raumdetails", + room_name: "Raumname", + make_public: "Öffentlicher Raum", + encrypt: "Verschlüsselter Raum", + room_name_required: "Muss angegeben werden", + alias_required_if_public: "Muss für öffentliche Räume angegeben werden.", + alias: "Alias", + alias_too_long: + "Darf zusammen mit der Domain des Homeservers 255 bytes nicht überschreiten", tabs: { basic: "Allgemein", members: "Mitglieder", @@ -67,6 +80,8 @@ export default { name: "Name", canonical_alias: "Alias", joined_members: "Mitglieder", + invite_members: "Mitglieder einladen", + invitees: "Einladungen", joined_local_members: "Lokale Mitglieder", state_events: "Ereignisse", version: "Version", diff --git a/src/i18n/en.js b/src/i18n/en.js index 12ba1ae..84c82a8 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -6,15 +6,30 @@ export default { auth: { base_url: "Homeserver URL", welcome: "Welcome to Synapse-admin", + server_version: "Synapse version", username_error: "Please enter fully qualified user ID: '@user:domain'", protocol_error: "URL has to start with 'http://' or 'https://'", url_error: "Not a valid Matrix server URL", }, + action: { + save_and_show: "Create QR code", + save_only: "Save", + download_pdf: "Download PDF", + }, users: { invalid_user_id: "Must be a fully qualified Matrix user-id, e.g. @user_id:homeserver", }, rooms: { + details: "Room Details", + room_name: "Room Name", + make_public: "Make room public", + encrypt: "Encrypt room", + room_name_required: "Must be provided", + alias_required_if_public: "Must be provided for a public room", + alias: "Alias", + alias_too_long: + "Must not exceed 255 bytes including the domain of the homeserver.", tabs: { basic: "Basic", members: "Members", @@ -65,6 +80,8 @@ export default { name: "Name", canonical_alias: "Alias", joined_members: "Members", + invite_members: "Invite Members", + invitees: "Invitations", joined_local_members: "local members", state_events: "State events", version: "Version", diff --git a/src/synapse/dataProvider.js b/src/synapse/dataProvider.js index cebc413..54c06d7 100644 --- a/src/synapse/dataProvider.js +++ b/src/synapse/dataProvider.js @@ -35,6 +35,7 @@ const resourceMap = { is_guest: !!u.is_guest, admin: !!u.admin, deactivated: !!u.deactivated, + displayname: u.display_name || u.displayname, // need timestamp in milliseconds creation_ts_ms: u.creation_ts * 1000, }), @@ -63,9 +64,31 @@ const resourceMap = { public: !!r.public, }), data: "rooms", - total: json => { - return json.total_rooms; - }, + total: json => json.total_rooms, + create: data => ({ + endpoint: "/_matrix/client/r0/createRoom", + body: { + name: data.name, + room_alias_name: data.canonical_alias, + visibility: data.public ? "public" : "private", + invite: + Array.isArray(data.invitees) && data.invitees.length > 0 + ? data.invitees + : undefined, + initial_state: data.encrypt + ? [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ] + : undefined, + }, + method: "POST", + }), }, connections: { path: "/_synapse/admin/v1/whois", diff --git a/yarn.lock b/yarn.lock index 7607589..e74cb12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1406,6 +1406,25 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== +"@progress/kendo-drawing@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@progress/kendo-drawing/-/kendo-drawing-1.6.0.tgz#66e9df431f52c7dd9fd5567be80dcbfa3a162281" + integrity sha512-9dGlFvW9fMgqAgcbLi+SfTeMUpNYdoVthwNxwAtsRQ+QwcgXJcdzFpLrLBXp17pXpDDFpiOyMqiwjffNGwtc3w== + dependencies: + pako "^1.0.5" + +"@progress/kendo-file-saver@^1.0.1": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@progress/kendo-file-saver/-/kendo-file-saver-1.0.7.tgz#5b602115d1b0b5e26f3e52451a3ed7c29ed76c51" + integrity sha512-8tsho/+DATzfTW4BBaHrkF3C3jqH2/bQ+XbjqA0KfmTiBRVK6ygK+tkvkYeDhFlQBbJ02MmJlEC6OmXvXRFkUg== + +"@progress/kendo-react-pdf@^3.10.1": + version "3.10.1" + resolved "https://registry.yarnpkg.com/@progress/kendo-react-pdf/-/kendo-react-pdf-3.10.1.tgz#348517daaddb366bbe840a92ec2fffbfd07ac2d2" + integrity sha512-2EKfQCwLFEa+mgCLKQ70iWVu7q2Dh/wJl6pPJ6Ix42BA7SiA2k5UmDk819FLY9pnMgv7WDxwqBP+8CvdkLoP5w== + dependencies: + "@progress/kendo-file-saver" "^1.0.1" + "@redux-saga/core@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4" @@ -8090,7 +8109,7 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pako@~1.0.5: +pako@^1.0.5, pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -9246,6 +9265,20 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8= + +qrcode.react@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-1.0.0.tgz#7e8889db3b769e555e8eb463d4c6de221c36d5de" + integrity sha512-jBXleohRTwvGBe1ngV+62QvEZ/9IZqQivdwzo9pJM4LQMoCM2VnvNBnKdjvGnKyDZ/l0nCDgsPod19RzlPvm/Q== + dependencies: + loose-envify "^1.4.0" + prop-types "^15.6.0" + qr.js "0.0.0" + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"