Compare commits

..

No commits in common. "7d4d765ab45cf0f5c5b07cd6ea3c6b1a7e5a5628" and "323ad9f9e29801251d18653d15c23772dc77cf63" have entirely different histories.

40 changed files with 5972 additions and 4776 deletions

View file

@ -12,10 +12,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Install dependencies
run: yarn --immutable
run: yarn --frozen-lockfile
- name: Run tests
run: yarn test

View file

@ -19,11 +19,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -43,7 +43,7 @@ jobs:
esac
echo "::set-output name=tag::$tag"
- name: Build and Push Tag
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
context: .
push: true

View file

@ -11,16 +11,16 @@ jobs:
steps:
- name: Checkout 🛎️
uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: "18"
- name: Install and Build 🔧
run: |
yarn install --immutable
yarn install
yarn build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4.5.0
uses: JamesIves/github-pages-deploy-action@v4.4.3
with:
branch: gh-pages
folder: build

View file

@ -14,10 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: "18"
- run: yarn install --immutable
- run: yarn install
- run: yarn build
- run: |
version=`git describe --dirty --tags || echo unknown`

View file

@ -19,11 +19,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -43,7 +43,7 @@ jobs:
esac
echo "::set-output name=tag::$tag"
- name: Build and Push Tag
uses: docker/build-push-action@v5
uses: docker/build-push-action@v4
with:
context: .
push: false

View file

@ -6,7 +6,7 @@ ARG REACT_APP_SERVER
WORKDIR /src
COPY . /src
RUN yarn --network-timeout=300000 install --immutable
RUN yarn --network-timeout=300000 install
RUN REACT_APP_SERVER=$REACT_APP_SERVER yarn build

View file

@ -13,10 +13,10 @@ This project is built using [react-admin](https://marmelab.com/react-admin/).
### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.52.0 for all functions to work as expected!
It needs at least [Synapse](https://github.com/matrix-org/synapse) v1.52.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`.
See also [Synapse version API](https://element-hq.github.io/synapse/latest/admin_api/version_api.html).
See also [Synapse version API](https://matrix-org.github.io/synapse/develop/admin_api/version_api.html).
After entering the URL on the login page of synapse-admin the server version appears below the input field.
@ -27,7 +27,7 @@ You need access to the following endpoints:
- `/_matrix`
- `/_synapse/admin`
See also [Synapse administration endpoints](https://element-hq.github.io/synapse/latest/reverse_proxy.html#synapse-administration-endpoints)
See also [Synapse administration endpoints](https://matrix-org.github.io/synapse/develop/reverse_proxy.html#synapse-administration-endpoints)
### Use without install

View file

@ -1,6 +1,6 @@
{
"name": "synapse-admin",
"version": "0.9.1",
"version": "0.8.5",
"description": "Admin GUI for the Matrix.org server Synapse",
"author": "Awesome Technologies Innovationslabor GmbH",
"license": "Apache-2.0",
@ -10,28 +10,31 @@
"url": "https://github.com/Awesome-Technologies/synapse-admin"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.2",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.4.3",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^4.2.1",
"jest-fetch-mock": "^3.0.3",
"prettier": "^3.2.5"
"prettier": "^2.2.0",
"ra-test": "^3.19.12"
},
"dependencies": {
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/styles": "^5.15.10",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.14.8",
"@mui/material": "^5.14.8",
"papaparse": "^5.4.1",
"prop-types": "^15.8.1",
"ra-language-chinese": "^2.0.10",
"ra-language-french": "^4.16.11",
"ra-language-french": "^4.13.3",
"ra-language-german": "^3.13.4",
"ra-language-italian": "^3.13.1",
"react": "^18.0.0",
"react-admin": "^4.16.11",
"react-dom": "^18.0.0",
"react": "^17.0.0",
"react-admin": "^3.19.12",
"react-dom": "^17.0.2",
"react-scripts": "^5.0.1"
},
"scripts": {

View file

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

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

107
src/App.js Normal file
View file

@ -0,0 +1,107 @@
import React from "react";
import { Admin, Resource, resolveBrowserLocale } from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList, RoomShow } from "./components/rooms";
import { ReportList, ReportShow } from "./components/EventReports";
import LoginPage from "./components/LoginPage";
import ConfirmationNumberIcon from "@mui/icons-material/ConfirmationNumber";
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import UserIcon from "@mui/icons-material/Group";
import { UserMediaStatsList } from "./components/statistics";
import RoomIcon from "@mui/icons-material/ViewList";
import ReportIcon from "@mui/icons-material/Warning";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import { DestinationList, DestinationShow } from "./components/destinations";
import { ImportFeature } from "./components/ImportFeature";
import {
RegistrationTokenCreate,
RegistrationTokenEdit,
RegistrationTokenList,
} from "./components/RegistrationTokens";
import { RoomDirectoryList } from "./components/RoomDirectory";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
import italianMessages from "./i18n/it";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
customRoutes={[
<Route key="userImport" path="/import_users" component={ImportFeature} />,
]}
>
<Resource
name="users"
list={UserList}
create={UserCreate}
edit={UserEdit}
icon={UserIcon}
/>
<Resource name="rooms" list={RoomList} show={RoomShow} icon={RoomIcon} />
<Resource
name="user_media_statistics"
list={UserMediaStatsList}
icon={EqualizerIcon}
/>
<Resource
name="reports"
list={ReportList}
show={ReportShow}
icon={ReportIcon}
/>
<Resource
name="room_directory"
list={RoomDirectoryList}
icon={FolderSharedIcon}
/>
<Resource
name="destinations"
list={DestinationList}
show={DestinationShow}
icon={CloudQueueIcon}
/>
<Resource
name="registration_tokens"
list={RegistrationTokenList}
create={RegistrationTokenCreate}
edit={RegistrationTokenEdit}
icon={ConfirmationNumberIcon}
/>
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
);
export default App;

View file

@ -1,72 +0,0 @@
import React from "react";
import {
Admin,
CustomRoutes,
Resource,
resolveBrowserLocale,
} from "react-admin";
import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider";
import users from "./components/users";
import rooms from "./components/rooms";
import userMediaStats from "./components/statistics";
import reports from "./components/EventReports";
import roomDirectory from "./components/RoomDirectory";
import destinations from "./components/destinations";
import registrationToken from "./components/RegistrationTokens";
import LoginPage from "./components/LoginPage";
import { ImportFeature } from "./components/ImportFeature";
import { Route } from "react-router-dom";
import germanMessages from "./i18n/de";
import englishMessages from "./i18n/en";
import frenchMessages from "./i18n/fr";
import chineseMessages from "./i18n/zh";
import italianMessages from "./i18n/it";
// TODO: Can we use lazy loading together with browser locale?
const messages = {
de: germanMessages,
en: englishMessages,
fr: frenchMessages,
it: italianMessages,
zh: chineseMessages,
};
const i18nProvider = polyglotI18nProvider(
locale => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
);
const App = () => (
<Admin
disableTelemetry
requireAuth
loginPage={LoginPage}
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />
</CustomRoutes>
<Resource {...users} />
<Resource {...rooms} />
<Resource {...userMediaStats} />
<Resource {...reports} />
<Resource {...roomDirectory} />
<Resource {...destinations} />
<Resource {...registrationToken} />
<Resource name="connections" />
<Resource name="devices" />
<Resource name="room_members" />
<Resource name="users_media" />
<Resource name="joined_rooms" />
<Resource name="pushers" />
<Resource name="servernotices" />
<Resource name="forward_extremities" />
<Resource name="room_state" />
<Resource name="destination_rooms" />
</Admin>
);
export default App;

View file

@ -1,22 +0,0 @@
import React from "react";
import get from "lodash/get";
import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin";
const AvatarField = ({ source, ...rest }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const { alt, classes, sizes, sx, variant } = rest;
return (
<Avatar
alt={alt}
classes={classes}
sizes={sizes}
src={src}
sx={sx}
variant={variant}
/>
);
};
export default AvatarField;

View file

@ -1,18 +0,0 @@
import React from "react";
import { RecordContextProvider } from "react-admin";
import { render, screen } from "@testing-library/react";
import AvatarField from "./AvatarField";
describe("AvatarField", () => {
it("shows image", () => {
const value = {
avatar: "foo",
};
render(
<RecordContextProvider value={value}>
<AvatarField source="avatar" />
</RecordContextProvider>
);
expect(screen.getByRole("img").getAttribute("src")).toBe("foo");
});
});

View file

@ -2,7 +2,6 @@ import React from "react";
import {
Datagrid,
DateField,
DeleteButton,
List,
NumberField,
Pagination,
@ -11,12 +10,9 @@ import {
Tab,
TabbedShowLayout,
TextField,
TopToolbar,
useRecordContext,
useTranslate,
} from "react-admin";
import PageviewIcon from "@mui/icons-material/Pageview";
import ReportIcon from "@mui/icons-material/Warning";
import ViewListIcon from "@mui/icons-material/ViewList";
const date_format = {
@ -28,14 +24,14 @@ const date_format = {
second: "2-digit",
};
const ReportPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const ReportPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
export const ReportShow = props => {
const translate = useTranslate();
return (
<Show {...props} actions={<ReportShowActions />}>
<Show {...props}>
<TabbedShowLayout>
<Tab
label={translate("synapseadmin.reports.tabs.basic", {
@ -94,7 +90,7 @@ export const ReportShow = props => {
<TextField source="event_json.content.algorithm" />
<TextField
source="event_json.content.device_id"
label="resources.devices.fields.device_id"
label="resources.users.fields.device_id"
/>
</Tab>
</TabbedShowLayout>
@ -102,28 +98,15 @@ export const ReportShow = props => {
);
};
const ReportShowActions = () => {
const record = useRecordContext();
export const ReportList = ({ ...props }) => {
return (
<TopToolbar>
<DeleteButton
record={record}
mutationMode="pessimistic"
confirmTitle="resources.reports.action.erase.title"
confirmContent="resources.reports.action.erase.content"
/>
</TopToolbar>
);
};
export const ReportList = props => (
<List
{...props}
pagination={<ReportPagination />}
sort={{ field: "received_ts", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick="show" bulkActionButtons={false}>
<Datagrid rowClick="show">
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
@ -136,13 +119,5 @@ export const ReportList = props => (
<TextField sortable={false} source="score" />
</Datagrid>
</List>
);
const resource = {
name: "reports",
icon: ReportIcon,
list: ReportList,
show: ReportShow,
);
};
export default resource;

View file

@ -1,6 +1,12 @@
import React, { useState } from "react";
import { useDataProvider, useNotify, Title } from "react-admin";
import {
Button as ReactAdminButton,
useDataProvider,
useNotify,
Title,
} from "react-admin";
import { parse as parseCsv, unparse as unparseCsv } from "papaparse";
import GetAppIcon from "@mui/icons-material/GetApp";
import {
Button,
Card,
@ -17,6 +23,19 @@ 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",
@ -32,7 +51,7 @@ function TranslatableOption({ value, text }) {
return <option value={value}>{translate(text)}</option>;
}
const FilePicker = () => {
const FilePicker = props => {
const [values, setValues] = useState(null);
const [error, setError] = useState(null);
const [stats, setStats] = useState(null);
@ -191,7 +210,7 @@ const FilePicker = () => {
return true;
};
const runImport = async _e => {
const runImport = async e => {
if (progress !== null) {
notify("import_users.errors.already_in_progress");
return;
@ -307,7 +326,7 @@ const FilePicker = () => {
let retries = 0;
const submitRecord = recordData => {
return dataProvider.getOne("users", { id: recordData.id }).then(
async _alreadyExists => {
async alreadyExists => {
if (LOGGING) console.log("already existed");
if (useridMode === "update" || conflictMode === "skip") {
@ -332,7 +351,7 @@ const FilePicker = () => {
}
}
},
async _okToSubmit => {
async okToSubmit => {
if (LOGGING)
console.log(
"OK to create record " +

378
src/components/LoginPage.js Normal file
View file

@ -0,0 +1,378 @@
import React, { useState, useEffect } from "react";
import {
fetchUtils,
FormDataConsumer,
Notification,
useLogin,
useNotify,
useLocale,
useSetLocale,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { Form, useForm } from "react-final-form";
import {
Avatar,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
TextField,
} from "@mui/material";
import { makeStyles } from "@material-ui/core/styles";
import LockIcon from "@mui/icons-material/Lock";
const useStyles = makeStyles(theme => ({
main: {
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1em)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
},
card: {
minWidth: "30em",
marginTop: "6em",
marginBottom: "6em",
},
avatar: {
margin: "1em",
display: "flex",
justifyContent: "center",
},
icon: {
backgroundColor: theme.palette.secondary.main,
},
hint: {
marginTop: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[500],
},
form: {
padding: "0 1em 1em 1em",
},
input: {
marginTop: "1em",
},
actions: {
padding: "0 1em 1em 1em",
},
serverVersion: {
color: "#9e9e9e",
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
}));
const LoginPage = ({ theme }) => {
const classes = useStyles({ theme });
const login = useLogin();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
var locale = useLocale();
const setLocale = useSetLocale();
const translate = useTranslate();
const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
if (loginToken) {
const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken);
// Prevent further requests
window.history.replaceState(
{},
"",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url");
if (baseUrl) {
const auth = {
base_url: baseUrl,
username: null,
password: null,
loginToken: ssoToken,
};
console.log("Base URL is:", baseUrl);
console.log("SSO Token is:", ssoToken);
console.log("Let's try token login...");
login(auth).catch(error => {
alert(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message
);
console.error(error);
});
}
}
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
);
const validate = values => {
const errors = {};
if (!values.username) {
errors.username = translate("ra.validation.required");
}
if (!values.password) {
errors.password = translate("ra.validation.required");
}
if (!values.base_url) {
errors.base_url = translate("ra.validation.required");
} else {
if (!values.base_url.match(/^(http|https):\/\//)) {
errors.base_url = translate("synapseadmin.auth.protocol_error");
} else if (
!values.base_url.match(
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/
)
) {
errors.base_url = translate("synapseadmin.auth.url_error");
}
}
return errors;
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
{ type: "warning" }
);
});
};
const handleSSO = () => {
localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const extractHomeServer = username => {
const usernameRegex = /@[a-zA-Z0-9._=\-/]+:([a-zA-Z0-9\-.]+\.[a-zA-Z]+)/;
if (!username) return null;
const res = username.match(usernameRegex);
if (res) return res[1];
return null;
};
const UserData = ({ formData }) => {
const form = useForm();
const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordially
const home_server = extractHomeServer(formData.username);
const wellKnownUrl = `https://${home_server}/.well-known/matrix/client`;
if (home_server) {
// fetch .well-known entry to get base_url
fetchUtils
.fetchJson(wellKnownUrl, { method: "GET" })
.then(({ json }) => {
form.change("base_url", json["m.homeserver"].base_url);
})
.catch(_ => {
// if there is no .well-known entry, try the home server name
form.change("base_url", `https://${home_server}`);
});
}
};
useEffect(
_ => {
if (
!formData.base_url ||
!formData.base_url.match(
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/
)
)
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("");
});
// Set SSO Url
const authMethodUrl = `${formData.base_url}/_matrix/client/r0/login`;
let supportPass = false,
supportSSO = false;
fetchUtils
.fetchJson(authMethodUrl, { method: "GET" })
.then(({ json }) => {
json.flows.forEach(f => {
if (f.type === "m.login.password") {
supportPass = true;
} else if (f.type === "m.login.sso") {
supportSSO = true;
}
});
setSupportPassAuth(supportPass);
if (supportSSO) {
setSSOBaseUrl(formData.base_url);
} else {
setSSOBaseUrl("");
}
})
.catch(_ => {
setSSOBaseUrl("");
});
},
[formData.base_url]
);
return (
<div>
<div className={classes.input}>
<TextInput
autoFocus
name="username"
component={renderInput}
label="ra.auth.username"
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<PasswordInput
name="password"
component={renderInput}
label="ra.auth.password"
type="password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
/>
</div>
<div className={classes.input}>
<TextInput
name="base_url"
component={renderInput}
label="synapseadmin.auth.base_url"
disabled={cfg_base_url || loading}
resettable
fullWidth
/>
</div>
<div className={classes.serverVersion}>{serverVersion}</div>
</div>
);
};
return (
<Form
initialValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<Avatar className={classes.icon}>
<LockIcon />
</Avatar>
</div>
<div className={classes.hint}>
{translate("synapseadmin.auth.welcome")}
</div>
<div className={classes.form}>
<div className={classes.input}>
<Select
value={locale}
onChange={e => {
setLocale(e.target.value);
}}
fullWidth
disabled={loading}
>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="zh">简体中文</MenuItem>
</Select>
</div>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading || !supportPassAuth}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate("ra.auth.sign_in")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleSSO}
disabled={loading || ssoBaseUrl === ""}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate("synapseadmin.auth.sso_sign_in")}
</Button>
</CardActions>
</Card>
<Notification />
</div>
</form>
)}
/>
);
};
export default LoginPage;

View file

@ -1,327 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Form,
FormDataConsumer,
Notification,
required,
useLogin,
useNotify,
useLocaleState,
useTranslate,
PasswordInput,
TextInput,
} from "react-admin";
import { useFormContext } from "react-hook-form";
import {
Avatar,
Box,
Button,
Card,
CardActions,
CircularProgress,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import LockIcon from "@mui/icons-material/Lock";
import {
getServerVersion,
getSupportedLoginFlows,
getWellKnownUrl,
isValidBaseUrl,
splitMxid,
} from "../synapse/synapse";
const FormBox = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 1em)",
alignItems: "center",
justifyContent: "flex-start",
background: "url(./images/floating-cogs.svg)",
backgroundColor: "#f9f9f9",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
[`& .card`]: {
minWidth: "30em",
marginTop: "6em",
marginBottom: "6em",
},
[`& .avatar`]: {
margin: "1em",
display: "flex",
justifyContent: "center",
},
[`& .icon`]: {
backgroundColor: theme.palette.grey[500],
},
[`& .hint`]: {
marginTop: "1em",
display: "flex",
justifyContent: "center",
color: theme.palette.grey[600],
},
[`& .form`]: {
padding: "0 1em 1em 1em",
},
[`& .input`]: {
marginTop: "1em",
},
[`& .actions`]: {
padding: "0 1em 1em 1em",
},
[`& .serverVersion`]: {
color: theme.palette.grey[500],
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
marginBottom: "1em",
marginLeft: "0.5em",
},
}));
const LoginPage = () => {
const login = useLogin();
const notify = useNotify();
const [loading, setLoading] = useState(false);
const [supportPassAuth, setSupportPassAuth] = useState(true);
const [locale, setLocale] = useLocaleState();
const translate = useTranslate();
const base_url = localStorage.getItem("base_url");
const cfg_base_url = process.env.REACT_APP_SERVER;
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href);
if (loginToken) {
const ssoToken = loginToken[1];
console.log("SSO token is", ssoToken);
// Prevent further requests
window.history.replaceState(
{},
"",
window.location.href.replace(loginToken[0], "#").split("#")[0]
);
const baseUrl = localStorage.getItem("sso_base_url");
localStorage.removeItem("sso_base_url");
if (baseUrl) {
const auth = {
base_url: baseUrl,
username: null,
password: null,
loginToken: ssoToken,
};
console.log("Base URL is:", baseUrl);
console.log("SSO Token is:", ssoToken);
console.log("Let's try token login...");
login(auth).catch(error => {
alert(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message
);
console.error(error);
});
}
}
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
);
const validateBaseUrl = value => {
if (!value.match(/^(http|https):\/\//)) {
return translate("synapseadmin.auth.protocol_error");
} else if (
!value.match(/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?[^?&\s]*$/)
) {
return translate("synapseadmin.auth.url_error");
} else {
return undefined;
}
};
const handleSubmit = auth => {
setLoading(true);
login(auth).catch(error => {
setLoading(false);
notify(
typeof error === "string"
? error
: typeof error === "undefined" || !error.message
? "ra.auth.sign_in_error"
: error.message,
{ type: "warning" }
);
});
};
const handleSSO = () => {
localStorage.setItem("sso_base_url", ssoBaseUrl);
const ssoFullUrl = `${ssoBaseUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(
window.location.href
)}`;
window.location.href = ssoFullUrl;
};
const UserData = ({ formData }) => {
const form = useFormContext();
const [serverVersion, setServerVersion] = useState("");
const handleUsernameChange = _ => {
if (formData.base_url || cfg_base_url) return;
// check if username is a full qualified userId then set base_url accordingly
const domain = splitMxid(formData.username)?.domain;
if (domain) {
getWellKnownUrl(domain).then(url => form.setValue("base_url", url));
}
};
useEffect(() => {
if (!isValidBaseUrl(formData.base_url)) return;
getServerVersion(formData.base_url)
.then(serverVersion =>
setServerVersion(
`${translate("synapseadmin.auth.server_version")} ${serverVersion}`
)
)
.catch(() => setServerVersion(""));
// Set SSO Url
getSupportedLoginFlows(formData.base_url)
.then(loginFlows => {
const supportPass =
loginFlows.find(f => f.type === "m.login.password") !== undefined;
const supportSSO =
loginFlows.find(f => f.type === "m.login.sso") !== undefined;
setSupportPassAuth(supportPass);
setSSOBaseUrl(supportSSO ? formData.base_url : "");
})
.catch(() => setSSOBaseUrl(""));
}, [formData.base_url]);
return (
<>
<Box>
<TextInput
autoFocus
name="username"
component={renderInput}
label="ra.auth.username"
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
className="input"
validate={required()}
/>
</Box>
<Box>
<PasswordInput
name="password"
component={renderInput}
label="ra.auth.password"
type="password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
className="input"
validate={required()}
/>
</Box>
<Box>
<TextInput
name="base_url"
component={renderInput}
label="synapseadmin.auth.base_url"
disabled={cfg_base_url || loading}
resettable
fullWidth
className="input"
validate={[required(), validateBaseUrl]}
/>
</Box>
<Typography className="serverVersion">{serverVersion}</Typography>
</>
);
};
return (
<Form
defaultValues={{ base_url: cfg_base_url || base_url }}
onSubmit={handleSubmit}
mode="onTouched"
>
<FormBox>
<Card className="card">
<Box className="avatar">
{loading ? (
<CircularProgress size={25} thickness={2} />
) : (
<Avatar className="icon">
<LockIcon />
</Avatar>
)}
</Box>
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
value={locale}
onChange={e => {
setLocale(e.target.value);
}}
fullWidth
disabled={loading}
className="input"
>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="zh">简体中文</MenuItem>
</Select>
<FormDataConsumer>
{formDataProps => <UserData {...formDataProps} />}
</FormDataConsumer>
<CardActions className="actions">
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading || !supportPassAuth}
fullWidth
>
{translate("ra.auth.sign_in")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleSSO}
disabled={loading || ssoBaseUrl === ""}
fullWidth
>
{translate("synapseadmin.auth.sso_sign_in")}
</Button>
</CardActions>
</Box>
</Card>
</FormBox>
<Notification />
</Form>
);
};
export default LoginPage;

View file

@ -1,14 +1,14 @@
import React from "react";
import { render } from "@testing-library/react";
import { AdminContext } from "react-admin";
import { TestContext } from "ra-test";
import LoginPage from "./LoginPage";
describe("LoginForm", () => {
it("renders", () => {
render(
<AdminContext>
<TestContext>
<LoginPage />
</AdminContext>
</TestContext>
);
});
});

39
src/components/Menu.js Normal file
View file

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

View file

@ -6,19 +6,18 @@ import {
DateField,
DateTimeInput,
Edit,
Filter,
List,
maxValue,
number,
NumberField,
NumberInput,
regex,
SaveButton,
SimpleForm,
TextInput,
TextField,
Toolbar,
} from "react-admin";
import RegistrationTokenIcon from "@mui/icons-material/ConfirmationNumber";
const date_format = {
year: "numeric",
@ -54,12 +53,17 @@ const dateFormatter = v => {
return `${year}-${month}-${day}T${hour}:${minute}`;
};
const registrationTokenFilters = [<BooleanInput source="valid" alwaysOn />];
const RegistrationTokenFilter = props => (
<Filter {...props}>
<BooleanInput source="valid" alwaysOn />
</Filter>
);
export const RegistrationTokenList = props => (
export const RegistrationTokenList = props => {
return (
<List
{...props}
filters={registrationTokenFilters}
filters={<RegistrationTokenFilter />}
filterDefaultValues={{ valid: true }}
pagination={false}
perPage={500}
@ -77,18 +81,12 @@ export const RegistrationTokenList = props => (
/>
</Datagrid>
</List>
);
);
};
export const RegistrationTokenCreate = props => (
<Create {...props} redirect="list">
<SimpleForm
toolbar={
<Toolbar>
{/* It is possible to create tokens per default without input. */}
<SaveButton alwaysEnable />
</Toolbar>
}
>
<Create {...props}>
<SimpleForm redirect="list" toolbar={<Toolbar alwaysEnableSaveButton />}>
<TextInput
source="token"
autoComplete="off"
@ -111,7 +109,8 @@ export const RegistrationTokenCreate = props => (
</Create>
);
export const RegistrationTokenEdit = props => (
export const RegistrationTokenEdit = props => {
return (
<Edit {...props}>
<SimpleForm>
<TextInput source="token" disabled />
@ -129,14 +128,5 @@ export const RegistrationTokenEdit = props => (
/>
</SimpleForm>
</Edit>
);
const resource = {
name: "registration_tokens",
icon: RegistrationTokenIcon,
list: RegistrationTokenList,
edit: RegistrationTokenEdit,
create: RegistrationTokenCreate,
);
};
export default resource;

View file

@ -0,0 +1,260 @@
import React, { Fragment } from "react";
import { Avatar, Chip } from "@mui/material";
import { connect } from "react-redux";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import { makeStyles } from "@material-ui/core/styles";
import {
BooleanField,
BulkDeleteButton,
Button,
Datagrid,
DeleteButton,
Filter,
List,
NumberField,
Pagination,
TextField,
useCreate,
useMutation,
useNotify,
useTranslate,
useRecordContext,
useRefresh,
useUnselectAll,
} from "react-admin";
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
});
const RoomDirectoryPagination = props => (
<Pagination {...props} rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryDeleteButton = props => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
};
export const RoomDirectoryBulkDeleteButton = props => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
mutationMode="pessimistic"
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<FolderSharedIcon />}
/>
);
export const RoomDirectoryBulkSaveButton = ({ selectedIds }) => {
const notify = useNotify();
const refresh = useRefresh();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const handleSend = values => {
createMany(
{
type: "createMany",
resource: "room_directory",
payload: { ids: selectedIds, data: {} },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
unselectAll("rooms");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
export const RoomDirectorySaveButton = props => {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [create, { loading }] = useCreate("room_directory");
const handleSend = values => {
create(
{
payload: { data: { id: record.id } },
},
{
onSuccess: ({ data }) => {
notify("resources.room_directory.action.send_success");
refresh();
},
onFailure: error =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={loading}
>
<FolderSharedIcon />
</Button>
);
};
const RoomDirectoryBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkDeleteButton {...props} />
</Fragment>
);
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
const RoomDirectoryFilter = ({ ...props }) => {
const translate = useTranslate();
return (
<Filter {...props}>
<Chip
label={translate("resources.rooms.fields.room_id")}
source="room_id"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.topic")}
source="topic"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
<Chip
label={translate("resources.rooms.fields.canonical_alias")}
source="canonical_alias"
defaultValue={false}
style={{ marginBottom: 8 }}
/>
</Filter>
);
};
export const FilterableRoomDirectoryList = ({
roomDirectoryFilters,
dispatch,
...props
}) => {
const classes = useStyles();
const filter = roomDirectoryFilters;
const roomIdFilter = filter && filter.room_id ? true : false;
const topicFilter = filter && filter.topic ? true : false;
const canonicalAliasFilter = filter && filter.canonical_alias ? true : false;
return (
<List
{...props}
pagination={<RoomDirectoryPagination />}
bulkActionButtons={<RoomDirectoryBulkActionButtons />}
filters={<RoomDirectoryFilter />}
perPage={100}
>
<Datagrid rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}>
<AvatarField
source="avatar_src"
sortable={false}
className={classes.small}
label="resources.rooms.fields.avatar"
/>
<TextField
source="name"
sortable={false}
label="resources.rooms.fields.name"
/>
{roomIdFilter && (
<TextField
source="room_id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
)}
{canonicalAliasFilter && (
<TextField
source="canonical_alias"
sortable={false}
label="resources.rooms.fields.canonical_alias"
/>
)}
{topicFilter && (
<TextField
source="topic"
sortable={false}
label="resources.rooms.fields.topic"
/>
)}
<NumberField
source="num_joined_members"
sortable={false}
label="resources.rooms.fields.joined_members"
/>
<BooleanField
source="world_readable"
sortable={false}
label="resources.room_directory.fields.world_readable"
/>
<BooleanField
source="guest_can_join"
sortable={false}
label="resources.room_directory.fields.guest_can_join"
/>
</Datagrid>
</List>
);
};
function mapStateToProps(state) {
return {
roomDirectoryFilters:
state.admin.resources.room_directory.list.params.displayedFilters,
};
}
export const RoomDirectoryList = connect(mapStateToProps)(
FilterableRoomDirectoryList
);

View file

@ -1,206 +0,0 @@
import React from "react";
import {
BooleanField,
BulkDeleteButton,
Button,
DatagridConfigurable,
ExportButton,
DeleteButton,
List,
NumberField,
Pagination,
SelectColumnsButton,
TextField,
TopToolbar,
useCreate,
useDataProvider,
useListContext,
useNotify,
useTranslate,
useRecordContext,
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import RoomDirectoryIcon from "@mui/icons-material/FolderShared";
import AvatarField from "./AvatarField";
const RoomDirectoryPagination = () => (
<Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />
);
export const RoomDirectoryUnpublishButton = props => {
const translate = useTranslate();
return (
<DeleteButton
{...props}
label="resources.room_directory.action.erase"
redirect={false}
mutationMode="pessimistic"
confirmTitle={translate("resources.room_directory.action.title", {
smart_count: 1,
})}
confirmContent={translate("resources.room_directory.action.content", {
smart_count: 1,
})}
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
};
export const RoomDirectoryBulkUnpublishButton = props => (
<BulkDeleteButton
{...props}
label="resources.room_directory.action.erase"
mutationMode="pessimistic"
confirmTitle="resources.room_directory.action.title"
confirmContent="resources.room_directory.action.content"
resource="room_directory"
icon={<RoomDirectoryIcon />}
/>
);
export const RoomDirectoryBulkPublishButton = props => {
const { selectedIds } = useListContext();
const notify = useNotify();
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
() =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
return (
<Button
{...props}
label="resources.room_directory.action.create"
onClick={mutate}
disabled={isLoading}
>
<RoomDirectoryIcon />
</Button>
);
};
export const RoomDirectoryPublishButton = props => {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
const handleSend = () => {
create(
"room_directory",
{ data: { id: record.id } },
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
};
return (
<Button
{...props}
label="resources.room_directory.action.create"
onClick={handleSend}
disabled={isLoading}
>
<RoomDirectoryIcon />
</Button>
);
};
const RoomDirectoryListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
export const RoomDirectoryList = () => (
<List
pagination={<RoomDirectoryPagination />}
perPage={100}
actions={<RoomDirectoryListActions />}
>
<DatagridConfigurable
rowClick={(id, _resource, _record) => "/rooms/" + id + "/show"}
bulkActionButtons={<RoomDirectoryBulkUnpublishButton />}
omit={["room_id", "canonical_alias", "topic"]}
>
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar"
/>
<TextField
source="name"
sortable={false}
label="resources.rooms.fields.name"
/>
<TextField
source="room_id"
sortable={false}
label="resources.rooms.fields.room_id"
/>
<TextField
source="canonical_alias"
sortable={false}
label="resources.rooms.fields.canonical_alias"
/>
<TextField
source="topic"
sortable={false}
label="resources.rooms.fields.topic"
/>
<NumberField
source="num_joined_members"
sortable={false}
label="resources.rooms.fields.joined_members"
/>
<BooleanField
source="world_readable"
sortable={false}
label="resources.room_directory.fields.world_readable"
/>
<BooleanField
source="guest_can_join"
sortable={false}
label="resources.room_directory.fields.guest_can_join"
/>
</DatagridConfigurable>
</List>
);
const resource = {
name: "room_directory",
icon: RoomDirectoryIcon,
list: RoomDirectoryList,
};
export default resource;

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { Fragment, useState } from "react";
import {
Button,
SaveButton,
@ -7,14 +7,12 @@ import {
Toolbar,
required,
useCreate,
useDataProvider,
useListContext,
useMutation,
useNotify,
useRecordContext,
useTranslate,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import MessageIcon from "@mui/icons-material/Message";
import IconCancel from "@mui/icons-material/Cancel";
import {
@ -24,7 +22,7 @@ import {
DialogTitle,
} from "@mui/material";
const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
const ServerNoticeDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const ServerNoticeToolbar = props => (
@ -48,7 +46,12 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
<DialogContentText>
{translate("resources.servernotices.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<ServerNoticeToolbar />} onSubmit={onSubmit}>
<SimpleForm
toolbar={<ServerNoticeToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<TextInput
source="body"
label="resources.servernotices.fields.body"
@ -64,25 +67,24 @@ const ServerNoticeDialog = ({ open, loading, onClose, onSubmit }) => {
);
};
export const ServerNoticeButton = () => {
export const ServerNoticeButton = props => {
const record = useRecordContext();
const [open, setOpen] = useState(false);
const notify = useNotify();
const [create, { isloading }] = useCreate();
const [create, { loading }] = useCreate("servernotices");
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
create(
"servernotices",
{ data: { id: record.id, ...values } },
{ payload: { data: { id: record.id, ...values } } },
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
handleDialogClose();
},
onError: () =>
onFailure: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
@ -91,65 +93,67 @@ export const ServerNoticeButton = () => {
};
return (
<>
<Fragment>
<Button
label="resources.servernotices.send"
onClick={handleDialogOpen}
disabled={isloading}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={handleDialogClose}
onSubmit={handleSend}
onSend={handleSend}
/>
</>
</Fragment>
);
};
export const ServerNoticeBulkButton = () => {
const { selectedIds } = useListContext();
export const ServerNoticeBulkButton = ({ selectedIds }) => {
const [open, setOpen] = useState(false);
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const notify = useNotify();
const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider();
const unselectAll = useUnselectAll();
const [createMany, { loading }] = useMutation();
const { mutate: sendNotices, isLoading } = useMutation(
data =>
dataProvider.createMany("servernotices", {
ids: selectedIds,
data: data,
}),
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleSend = values => {
createMany(
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
type: "createMany",
resource: "servernotices",
payload: { ids: selectedIds, data: values },
},
onError: () =>
{
onSuccess: ({ data }) => {
notify("resources.servernotices.action.send_success");
unselectAll("users");
handleDialogClose();
},
onFailure: error =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
};
return (
<>
<Fragment>
<Button
label="resources.servernotices.send"
onClick={openDialog}
disabled={isLoading}
onClick={handleDialogOpen}
disabled={loading}
>
<MessageIcon />
</Button>
<ServerNoticeDialog
open={open}
onClose={closeDialog}
onSubmit={sendNotices}
onClose={handleDialogClose}
onSend={handleSend}
/>
</>
</Fragment>
);
};

View file

@ -3,6 +3,7 @@ import {
Button,
Datagrid,
DateField,
Filter,
List,
Pagination,
ReferenceField,
@ -20,12 +21,11 @@ import {
useTranslate,
} from "react-admin";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import DestinationsIcon from "@mui/icons-material/CloudQueue";
import FolderSharedIcon from "@mui/icons-material/FolderShared";
import ViewListIcon from "@mui/icons-material/ViewList";
const DestinationPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const DestinationPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const date_format = {
@ -37,17 +37,23 @@ const date_format = {
second: "2-digit",
};
const destinationRowSx = (record, _index) => ({
const destinationRowStyle = (record, index) => ({
backgroundColor: record.retry_last_ts > 0 ? "#ffcccc" : "white",
});
const destinationFilters = [<SearchInput source="destination" alwaysOn />];
const DestinationFilter = ({ ...props }) => {
return (
<Filter {...props}>
<SearchInput source="destination" alwaysOn />
</Filter>
);
};
export const DestinationReconnectButton = () => {
export const DestinationReconnectButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [handleReconnect, { isLoading }] = useDelete();
const [handleReconnect, { isLoading }] = useDelete("destinations");
// Reconnect is not required if no error has occurred. (`failure_ts`)
if (!record || !record.failure_ts) return null;
@ -57,8 +63,7 @@ export const DestinationReconnectButton = () => {
e.stopPropagation();
handleReconnect(
"destinations",
{ id: record.id },
{ payload: { id: record.id } },
{
onSuccess: () => {
notify("ra.notification.updated", {
@ -66,7 +71,7 @@ export const DestinationReconnectButton = () => {
});
refresh();
},
onError: () => {
onFailure: () => {
notify("ra.message.error", { type: "error" });
},
}
@ -84,13 +89,13 @@ export const DestinationReconnectButton = () => {
);
};
const DestinationShowActions = () => (
const DestinationShowActions = props => (
<TopToolbar>
<DestinationReconnectButton />
</TopToolbar>
);
const DestinationTitle = () => {
const DestinationTitle = props => {
const record = useRecordContext();
const translate = useTranslate();
return (
@ -104,14 +109,14 @@ export const DestinationList = props => {
return (
<List
{...props}
filters={destinationFilters}
filters={<DestinationFilter />}
pagination={<DestinationPagination />}
sort={{ field: "destination", order: "ASC" }}
bulkActionButtons={false}
>
<Datagrid
rowSx={destinationRowSx}
rowClick={(id, _resource, _record) => `${id}/show/rooms`}
bulkActionButtons={false}
rowStyle={destinationRowStyle}
rowClick={(id, basePath, record) => `${basePath}/${id}/show/rooms`}
>
<TextField source="destination" />
<DateField source="failure_ts" showTime options={date_format} />
@ -155,7 +160,7 @@ export const DestinationShow = props => {
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => `/rooms/${id}/show`}
rowClick={(id, basePath, record) => `/rooms/${id}/show`}
>
<TextField
source="room_id"
@ -178,12 +183,3 @@ export const DestinationShow = props => {
</Show>
);
};
const resource = {
name: "destinations",
icon: DestinationsIcon,
list: DestinationList,
show: DestinationShow,
};
export default resource;

84
src/components/devices.js Normal file
View file

@ -0,0 +1,84 @@
import React, { Fragment, useState } from "react";
import {
Button,
useDelete,
useNotify,
Confirm,
useRecordContext,
useRefresh,
} from "react-admin";
import ActionDelete from "@mui/icons-material/Delete";
import { makeStyles } from "@material-ui/core/styles";
import { alpha } from "@mui/material/styles";
import classnames from "classnames";
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice, { isLoading }] = useDelete("devices");
if (!record) return null;
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleConfirm = () => {
removeDevice(
{ payload: { id: record.id, user_id: record.user_id } },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onFailure: () => {
notify("resources.devices.action.erase.failure", { type: "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={isLoading}
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>
);
};

View file

@ -1,51 +0,0 @@
import React from "react";
import {
DeleteButton,
useDelete,
useNotify,
useRecordContext,
useRefresh,
} from "react-admin";
export const DeviceRemoveButton = props => {
const record = useRecordContext();
const refresh = useRefresh();
const notify = useNotify();
const [removeDevice] = useDelete();
if (!record) return null;
const handleConfirm = () => {
removeDevice(
"devices",
// needs previousData for user_id
{ id: record.id, previousData: record },
{
onSuccess: () => {
notify("resources.devices.action.erase.success");
refresh();
},
onError: () => {
notify("resources.devices.action.erase.failure", { type: "error" });
},
}
);
};
return (
<DeleteButton
{...props}
label="ra.action.remove"
confirmTitle="resources.devices.action.erase.title"
confirmContent="resources.devices.action.erase.content"
onConfirm={handleConfirm}
mutationMode="pessimistic"
redirect={false}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,
}}
/>
);
};

View file

@ -1,4 +1,7 @@
import React, { useState } from "react";
import React, { Fragment, useState } from "react";
import classnames from "classnames";
import { alpha } from "@mui/material/styles";
import { makeStyles } from "@material-ui/core/styles";
import {
BooleanInput,
Button,
@ -27,9 +30,24 @@ import {
import IconCancel from "@mui/icons-material/Cancel";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import { alpha, useTheme } from "@mui/material/styles";
const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
const useStyles = makeStyles(
theme => ({
deleteButton: {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
},
}),
{ name: "RaDeleteDeviceButton" }
);
const DeleteMediaDialog = ({ open, loading, onClose, onSend }) => {
const translate = useTranslate();
const dateParser = v => {
@ -38,7 +56,8 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
return d.getTime();
};
const DeleteMediaToolbar = props => (
const DeleteMediaToolbar = props => {
return (
<Toolbar {...props}>
<SaveButton
label="resources.delete_media.action.send"
@ -49,6 +68,7 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
</Button>
</Toolbar>
);
};
return (
<Dialog open={open} onClose={onClose} loading={loading}>
@ -59,7 +79,12 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
<DialogContentText>
{translate("resources.delete_media.helper.send")}
</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<SimpleForm
toolbar={<DeleteMediaToolbar />}
submitOnEnter={false}
redirect={false}
save={onSend}
>
<DateTimeInput
fullWidth
source="before_ts"
@ -88,25 +113,23 @@ const DeleteMediaDialog = ({ open, loading, onClose, onSubmit }) => {
};
export const DeleteMediaButton = props => {
const theme = useTheme();
const classes = useStyles(props);
const [open, setOpen] = useState(false);
const notify = useNotify();
const [deleteOne, { isLoading }] = useDelete();
const [deleteOne, { loading }] = useDelete("delete_media");
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const deleteMedia = values => {
const handleSend = values => {
deleteOne(
"delete_media",
// needs meta.before_ts, meta.size_gt and meta.keep_profiles
{ meta: values },
{ payload: { ...values } },
{
onSuccess: () => {
notify("resources.delete_media.action.send_success");
closeDialog();
handleDialogClose();
},
onError: () =>
onFailure: () =>
notify("resources.delete_media.action.send_failure", {
type: "error",
}),
@ -115,54 +138,43 @@ export const DeleteMediaButton = props => {
};
return (
<>
<Fragment>
<Button
{...props}
label="resources.delete_media.action.send"
onClick={openDialog}
disabled={isLoading}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: alpha(theme.palette.error.main, 0.12),
// Reset on mouse devices
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
}}
onClick={handleDialogOpen}
disabled={loading}
className={classnames("ra-delete-button", classes.deleteButton)}
>
<DeleteSweepIcon />
</Button>
<DeleteMediaDialog
open={open}
onClose={closeDialog}
onSubmit={deleteMedia}
onClose={handleDialogClose}
onSend={handleSend}
/>
</>
</Fragment>
);
};
export const ProtectMediaButton = () => {
export const ProtectMediaButton = props => {
const record = useRecordContext();
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
const [create, { loading }] = useCreate("protect_media");
const [deleteOne] = useDelete("protect_media");
if (!record) return null;
const handleProtect = () => {
create(
"protect_media",
{ data: record },
{ payload: { data: record } },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onError: () =>
onFailure: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
@ -172,14 +184,13 @@ export const ProtectMediaButton = () => {
const handleUnprotect = () => {
deleteOne(
"protect_media",
{ id: record.id },
{ payload: { ...record } },
{
onSuccess: () => {
notify("resources.protect_media.action.send_success");
refresh();
},
onError: () =>
onFailure: () =>
notify("resources.protect_media.action.send_failure", {
type: "error",
}),
@ -192,7 +203,7 @@ export const ProtectMediaButton = () => {
Wrapping Tooltip with <div>
https://github.com/marmelab/react-admin/issues/4349#issuecomment-578594735
*/
<>
<Fragment>
{record.quarantined_by && (
<Tooltip
title={translate("resources.protect_media.action.none", {
@ -218,7 +229,7 @@ export const ProtectMediaButton = () => {
arrow
>
<div>
<Button onClick={handleUnprotect} disabled={isLoading}>
<Button onClick={handleUnprotect} disabled={loading}>
<LockIcon />
</Button>
</div>
@ -231,13 +242,13 @@ export const ProtectMediaButton = () => {
})}
>
<div>
<Button onClick={handleProtect} disabled={isLoading}>
<Button onClick={handleProtect} disabled={loading}>
<LockOpenIcon />
</Button>
</div>
</Tooltip>
)}
</>
</Fragment>
);
};
@ -246,21 +257,20 @@ export const QuarantineMediaButton = props => {
const translate = useTranslate();
const refresh = useRefresh();
const notify = useNotify();
const [create, { isLoading }] = useCreate();
const [deleteOne] = useDelete();
const [create, { loading }] = useCreate("quarantine_media");
const [deleteOne] = useDelete("quarantine_media");
if (!record) return null;
const handleQuarantaine = () => {
create(
"quarantine_media",
{ data: record },
{ payload: { data: record } },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onError: () =>
onFailure: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
@ -270,14 +280,13 @@ export const QuarantineMediaButton = props => {
const handleRemoveQuarantaine = () => {
deleteOne(
"quarantine_media",
{ id: record.id, previousData: record },
{ payload: { ...record } },
{
onSuccess: () => {
notify("resources.quarantine_media.action.send_success");
refresh();
},
onError: () =>
onFailure: () =>
notify("resources.quarantine_media.action.send_failure", {
type: "error",
}),
@ -286,7 +295,7 @@ export const QuarantineMediaButton = props => {
};
return (
<>
<Fragment>
{record.safe_from_quarantine && (
<Tooltip
title={translate("resources.quarantine_media.action.none", {
@ -294,7 +303,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button {...props} disabled={true}>
<Button disabled={true}>
<ClearIcon />
</Button>
</div>
@ -307,11 +316,7 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button
{...props}
onClick={handleRemoveQuarantaine}
disabled={isLoading}
>
<Button onClick={handleRemoveQuarantaine} disabled={loading}>
<BlockIcon color="error" />
</Button>
</div>
@ -324,12 +329,12 @@ export const QuarantineMediaButton = props => {
})}
>
<div>
<Button onClick={handleQuarantaine} disabled={isLoading}>
<Button onClick={handleQuarantaine} disabled={loading}>
<BlockIcon />
</Button>
</div>
</Tooltip>
)}
</>
</Fragment>
);
};

View file

@ -1,20 +1,18 @@
import React from "react";
import React, { Fragment } from "react";
import { connect } from "react-redux";
import {
BooleanField,
BulkDeleteButton,
DateField,
Datagrid,
DatagridConfigurable,
DeleteButton,
ExportButton,
FunctionField,
Filter,
List,
NumberField,
Pagination,
ReferenceField,
ReferenceManyField,
SearchInput,
SelectColumnsButton,
SelectField,
Show,
Tab,
@ -24,8 +22,10 @@ import {
useRecordContext,
useTranslate,
} from "react-admin";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import get from "lodash/get";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import { Tooltip, Typography, Chip } from "@mui/material";
import FastForwardIcon from "@mui/icons-material/FastForward";
import HttpsIcon from "@mui/icons-material/Https";
import NoEncryptionIcon from "@mui/icons-material/NoEncryption";
@ -34,12 +34,11 @@ import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EventIcon from "@mui/icons-material/Event";
import RoomIcon from "@mui/icons-material/ViewList";
import {
RoomDirectoryBulkUnpublishButton,
RoomDirectoryBulkPublishButton,
RoomDirectoryUnpublishButton,
RoomDirectoryPublishButton,
RoomDirectoryBulkDeleteButton,
RoomDirectoryBulkSaveButton,
RoomDirectoryDeleteButton,
RoomDirectorySaveButton,
} from "./RoomDirectory";
const date_format = {
@ -51,11 +50,44 @@ const date_format = {
second: "2-digit",
};
const RoomPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const useStyles = makeStyles(theme => ({
helper_forward_extremities: {
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
},
}));
const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const RoomTitle = () => {
const EncryptionField = ({ source, record = {}, emptyText }) => {
const translate = useTranslate();
const value = get(record, source);
let ariaLabel = value === false ? "ra.boolean.false" : "ra.boolean.true";
if (value === false || value === true) {
return (
<Typography component="span" variant="body2">
<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 = props => {
const record = useRecordContext();
const translate = useTranslate();
var name = "";
@ -70,18 +102,24 @@ const RoomTitle = () => {
);
};
const RoomShowActions = () => {
const record = useRecordContext();
const RoomShowActions = ({ basePath, data, resource }) => {
var roomDirectoryStatus = "";
if (record) {
roomDirectoryStatus = record.public;
if (data) {
roomDirectoryStatus = data.public;
}
return (
<TopToolbar>
{roomDirectoryStatus === false && <RoomDirectoryPublishButton />}
{roomDirectoryStatus === true && <RoomDirectoryUnpublishButton />}
{roomDirectoryStatus === false && (
<RoomDirectorySaveButton record={data} />
)}
{roomDirectoryStatus === true && (
<RoomDirectoryDeleteButton record={data} />
)}
<DeleteButton
basePath={basePath}
record={data}
resource={resource}
mutationMode="pessimistic"
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
@ -91,6 +129,7 @@ const RoomShowActions = () => {
};
export const RoomShow = props => {
const classes = useStyles({ props });
const translate = useTranslate();
return (
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
@ -98,7 +137,6 @@ export const RoomShow = props => {
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<TextField source="room_id" />
<TextField source="name" />
<TextField source="topic" />
<TextField source="canonical_alias" />
<ReferenceField source="creator" reference="users">
<TextField source="id" />
@ -133,8 +171,7 @@ export const RoomShow = props => {
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/users/" + id}
bulkActionButtons={false}
rowClick={(id, basePath, record) => "/users/" + id}
>
<TextField
source="id"
@ -219,7 +256,7 @@ export const RoomShow = props => {
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="type" sortable={false} />
<DateField
source="origin_server_ts"
@ -244,20 +281,15 @@ export const RoomShow = props => {
icon={<FastForwardIcon />}
path="forward_extremities"
>
<Box
sx={{
fontFamily: "Roboto, Helvetica, Arial, sans-serif",
margin: "0.5em",
}}
>
<div className={classes.helper_forward_extremities}>
{translate("resources.rooms.helper.forward_extremities")}
</Box>
</div>
<ReferenceManyField
reference="forward_extremities"
target="room_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="id" sortable={false} />
<DateField
source="received_ts"
@ -275,81 +307,104 @@ export const RoomShow = props => {
);
};
const RoomBulkActionButtons = () => (
<>
<RoomDirectoryBulkPublishButton />
<RoomDirectoryBulkUnpublishButton />
const RoomBulkActionButtons = props => (
<Fragment>
<RoomDirectoryBulkSaveButton {...props} />
<RoomDirectoryBulkDeleteButton {...props} />
<BulkDeleteButton
{...props}
confirmTitle="resources.rooms.action.erase.title"
confirmContent="resources.rooms.action.erase.content"
mutationMode="pessimistic"
/>
</>
</Fragment>
);
const roomFilters = [<SearchInput source="search_term" alwaysOn />];
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 RoomListActions = () => (
<TopToolbar>
<SelectColumnsButton />
<ExportButton />
</TopToolbar>
);
const RoomNameField = props => {
const { source } = props;
const record = useRecordContext();
return (
<span>{record[source] || record["canonical_alias"] || record["id"]}</span>
);
};
export const RoomList = props => {
const theme = useTheme();
RoomNameField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired,
};
const FilterableRoomList = ({ roomFilters, dispatch, ...props }) => {
const filter = 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;
return (
<List
{...props}
pagination={<RoomPagination />}
sort={{ field: "name", order: "ASC" }}
filters={roomFilters}
actions={<RoomListActions />}
>
<DatagridConfigurable
rowClick="show"
filters={<RoomFilter />}
bulkActionButtons={<RoomBulkActionButtons />}
omit={[
"joined_local_members",
"state_events",
"version",
"federatable",
]}
>
<BooleanField
<Datagrid rowClick="show">
<EncryptionField
source="is_encrypted"
sortBy="encryption"
TrueIcon={HttpsIcon}
FalseIcon={NoEncryptionIcon}
label={<HttpsIcon />}
sx={{
[`& [data-testid="true"]`]: { color: theme.palette.success.main },
[`& [data-testid="false"]`]: { color: theme.palette.error.main },
}}
/>
<FunctionField
source="name"
render={record =>
record["name"] || record["canonical_alias"] || record["id"]
}
/>
<RoomNameField source="name" />
<TextField source="joined_members" />
<TextField source="joined_local_members" />
<TextField source="state_events" />
<TextField source="version" />
<BooleanField source="federatable" />
{localMembersFilter && <TextField source="joined_local_members" />}
{stateEventsFilter && <TextField source="state_events" />}
{versionFilter && <TextField source="version" />}
{federateableFilter && <BooleanField source="federatable" />}
<BooleanField source="public" />
</DatagridConfigurable>
</Datagrid>
</List>
);
};
const resource = {
name: "rooms",
icon: RoomIcon,
list: RoomList,
show: RoomShow,
};
function mapStateToProps(state) {
return {
roomFilters: state.admin.resources.rooms.list.params.displayedFilters,
};
}
export default resource;
export const RoomList = connect(mapStateToProps)(FilterableRoomList);

View file

@ -0,0 +1,81 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
Filter,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const {
currentSort,
resource,
displayedFilters,
filterValues,
showFilter,
total,
} = useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={currentSort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const UserMediaStatsFilter = props => (
<Filter {...props}>
<SearchInput source="search_term" alwaysOn />
</Filter>
);
export const UserMediaStatsList = props => {
return (
<List
{...props}
actions={<ListActions />}
filters={<UserMediaStatsFilter />}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
bulkActionButtons={false}
>
<Datagrid rowClick={(id, basePath, record) => "/users/" + id + "/media"}>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
};

View file

@ -1,79 +0,0 @@
import React from "react";
import { cloneElement } from "react";
import {
Datagrid,
ExportButton,
List,
NumberField,
Pagination,
sanitizeListRestProps,
SearchInput,
TextField,
TopToolbar,
useListContext,
} from "react-admin";
import EqualizerIcon from "@mui/icons-material/Equalizer";
import { DeleteMediaButton } from "./media";
const ListActions = props => {
const { className, exporter, filters, maxResults, ...rest } = props;
const { sort, resource, displayedFilters, filterValues, showFilter, total } =
useListContext();
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: "button",
})}
<DeleteMediaButton />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
filterValues={filterValues}
maxResults={maxResults}
/>
</TopToolbar>
);
};
const UserMediaStatsPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userMediaStatsFilters = [<SearchInput source="search_term" alwaysOn />];
export const UserMediaStatsList = props => (
<List
{...props}
actions={<ListActions />}
filters={userMediaStatsFilters}
pagination={<UserMediaStatsPagination />}
sort={{ field: "media_length", order: "DESC" }}
>
<Datagrid
rowClick={(id, resource, record) => "/users/" + id + "/media"}
bulkActionButtons={false}
>
<TextField source="user_id" label="resources.users.fields.id" />
<TextField
source="displayname"
label="resources.users.fields.displayname"
/>
<NumberField source="media_count" />
<NumberField source="media_length" />
</Datagrid>
</List>
);
const resource = {
name: "user_media_statistics",
icon: EqualizerIcon,
list: UserMediaStatsList,
};
export default resource;

View file

@ -1,4 +1,5 @@
import React, { cloneElement } from "react";
import React, { cloneElement, Fragment } from "react";
import Avatar from "@mui/material/Avatar";
import AssignmentIndIcon from "@mui/icons-material/AssignmentInd";
import ContactMailIcon from "@mui/icons-material/ContactMail";
import DevicesIcon from "@mui/icons-material/Devices";
@ -7,7 +8,6 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
import PermMediaIcon from "@mui/icons-material/PermMedia";
import PersonPinIcon from "@mui/icons-material/PersonPin";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
import UserIcon from "@mui/icons-material/Group";
import ViewListIcon from "@mui/icons-material/ViewList";
import {
ArrayInput,
@ -18,6 +18,7 @@ import {
Create,
Edit,
List,
Filter,
Toolbar,
SimpleForm,
SimpleFormIterator,
@ -48,10 +49,28 @@ import {
NumberField,
} from "react-admin";
import { Link } from "react-router-dom";
import AvatarField from "./AvatarField";
import { ServerNoticeButton, ServerNoticeBulkButton } from "./ServerNotices";
import { DeviceRemoveButton } from "./devices";
import { ProtectMediaButton, QuarantineMediaButton } from "./media";
import { makeStyles } from "@material-ui/core/styles";
const redirect = () => {
return {
pathname: "/import_users",
};
};
const useStyles = makeStyles({
small: {
height: "40px",
width: "40px",
},
large: {
height: "120px",
width: "120px",
float: "right",
},
});
const choices_medium = [
{ id: "email", name: "resources.users.email" },
@ -73,7 +92,7 @@ const date_format = {
};
const UserListActions = ({
sort,
currentSort,
className,
resource,
filters,
@ -82,6 +101,7 @@ const UserListActions = ({
filterValues,
permanentFilter,
hasCreate, // you can hide CreateButton if hasCreate = false
basePath,
selectedIds,
onUnselectItems,
showFilter,
@ -99,18 +119,18 @@ const UserListActions = ({
filterValues,
context: "button",
})}
<CreateButton />
<CreateButton basePath={basePath} />
<ExportButton
disabled={total === 0}
resource={resource}
sort={sort}
sort={currentSort}
filter={{ ...filterValues, ...permanentFilter }}
exporter={exporter}
maxResults={maxResults}
/>
{/* Add your custom actions */}
<Button component={Link} to="/import_users" label="CSV Import">
<GetAppIcon sx={{ transform: "rotate(180deg)", fontSize: "20px" }} />
<Button component={Link} to={redirect} label="CSV Import">
<GetAppIcon style={{ transform: "rotate(180deg)", fontSize: "20" }} />
</Button>
</TopToolbar>
);
@ -121,44 +141,54 @@ UserListActions.defaultProps = {
onUnselectItems: () => null,
};
const UserPagination = () => (
<Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
const UserPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
);
const userFilters = [
<SearchInput source="name" alwaysOn />,
<BooleanInput source="guests" alwaysOn />,
const UserFilter = props => (
<Filter {...props}>
<SearchInput source="name" alwaysOn />
<BooleanInput source="guests" alwaysOn />
<BooleanInput
label="resources.users.fields.show_deactivated"
source="deactivated"
alwaysOn
/>,
];
/>
</Filter>
);
const UserBulkActionButtons = () => (
<>
<ServerNoticeBulkButton />
const UserBulkActionButtons = props => (
<Fragment>
<ServerNoticeBulkButton {...props} />
<BulkDeleteButton
{...props}
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic"
/>
</>
</Fragment>
);
export const UserList = props => (
const AvatarField = ({ source, className, record = {} }) => (
<Avatar src={record[source]} className={className} />
);
export const UserList = props => {
const classes = useStyles();
return (
<List
{...props}
filters={userFilters}
filters={<UserFilter />}
filterDefaultValues={{ guests: true, deactivated: false }}
sort={{ field: "name", order: "ASC" }}
actions={<UserListActions maxResults={10000} />}
bulkActionButtons={<UserBulkActionButtons />}
pagination={<UserPagination />}
>
<Datagrid rowClick="edit" bulkActionButtons={<UserBulkActionButtons />}>
<Datagrid rowClick="edit">
<AvatarField
source="avatar_src"
sx={{ height: "40px", width: "40px" }}
className={classes.small}
sortBy="avatar_url"
/>
<TextField source="id" sortBy="name" />
@ -174,7 +204,8 @@ export const UserList = props => (
/>
</Datagrid>
</List>
);
);
};
// https://matrix.org/docs/spec/appendices#user-identifiers
// here only local part of user_id
@ -231,7 +262,7 @@ export function generateRandomUser() {
const UserEditToolbar = props => (
<Toolbar {...props}>
<SaveButton disabled={props.pristine} />
<SaveButton submitOnEnter={true} disabled={props.pristine} />
</Toolbar>
);
@ -271,6 +302,7 @@ export const UserCreate = props => (
source="user_type"
choices={choices_type}
translateChoice={false}
allowEmpty={true}
resettable
/>
<BooleanInput source="admin" />
@ -298,7 +330,7 @@ export const UserCreate = props => (
</Create>
);
const UserTitle = () => {
const UserTitle = props => {
const record = useRecordContext();
const translate = useTranslate();
return (
@ -312,6 +344,7 @@ const UserTitle = () => {
};
export const UserEdit = props => {
const classes = useStyles();
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
@ -323,7 +356,7 @@ export const UserEdit = props => {
<AvatarField
source="avatar_src"
sortable={false}
sx={{ height: "120px", width: "120px", float: "right" }}
className={classes.large}
/>
<TextInput source="id" disabled />
<TextInput source="displayname" />
@ -336,6 +369,7 @@ export const UserEdit = props => {
source="user_type"
choices={choices_type}
translateChoice={false}
allowEmpty={true}
resettable
/>
<BooleanInput source="admin" />
@ -417,7 +451,7 @@ export const UserEdit = props => {
source="devices[].sessions[0].connections"
label="resources.connections.name"
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="ip" sortable={false} />
<DateField
source="last_seen"
@ -479,8 +513,7 @@ export const UserEdit = props => {
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, resource, record) => "/rooms/" + id + "/show"}
bulkActionButtons={false}
rowClick={(id, basePath, record) => "/rooms/" + id + "/show"}
>
<TextField
source="id"
@ -510,7 +543,7 @@ export const UserEdit = props => {
target="user_id"
addLabel={false}
>
<Datagrid style={{ width: "100%" }} bulkActionButtons={false}>
<Datagrid style={{ width: "100%" }}>
<TextField source="kind" sortable={false} />
<TextField source="app_display_name" sortable={false} />
<TextField source="app_id" sortable={false} />
@ -526,13 +559,3 @@ export const UserEdit = props => {
</Edit>
);
};
const resource = {
name: "users",
icon: UserIcon,
list: UserList,
edit: UserEdit,
create: UserCreate,
};
export default resource;

View file

@ -188,7 +188,7 @@ const de = {
},
},
reports: {
name: "Gemeldetes Ereignis |||| Gemeldete Ereignisse",
name: "Ereignisbericht |||| Ereignisberichte",
fields: {
id: "ID",
received_ts: "Meldezeit",
@ -210,13 +210,6 @@ const de = {
},
},
},
action: {
erase: {
title: "Gemeldetes Event löschen",
content:
"Sind Sie sicher dass Sie das gemeldete Event löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
},
},
},
connections: {
name: "Verbindungen",

View file

@ -207,13 +207,6 @@ const en = {
},
},
},
action: {
erase: {
title: "Delete reported event",
content:
"Are you sure you want to delete the reported event? This cannot be undone.",
},
},
},
connections: {
name: "Connections",

5
src/index.js Normal file
View file

@ -0,0 +1,5 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

View file

@ -1,9 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -98,7 +98,7 @@ const resourceMap = {
}),
delete: params => ({
endpoint: `/_synapse/admin/v2/users/${encodeURIComponent(
params.previousData.user_id
params.user_id
)}/devices/${params.id}`,
}),
},
@ -184,9 +184,9 @@ const resourceMap = {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/${localStorage.getItem(
"home_server"
)}/delete?before_ts=${params.meta.before_ts}&size_gt=${
params.meta.size_gt
}&keep_profiles=${params.meta.keep_profiles}`,
)}/delete?before_ts=${params.before_ts}&size_gt=${
params.size_gt
}&keep_profiles=${params.keep_profiles}`,
method: "POST",
}),
},
@ -197,7 +197,7 @@ const resourceMap = {
method: "POST",
}),
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unprotect/${params.id}`,
endpoint: `/_synapse/admin/v1/media/unprotect/${params.media_id}`,
method: "POST",
}),
},
@ -212,7 +212,7 @@ const resourceMap = {
delete: params => ({
endpoint: `/_synapse/admin/v1/media/unquarantine/${localStorage.getItem(
"home_server"
)}/${params.id}`,
)}/${params.media_id}`,
method: "POST",
}),
},
@ -456,7 +456,7 @@ const dataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.id)}`, {
return jsonClient(`${endpoint_url}/${encodeURIComponent(params.data.id)}`, {
method: "PUT",
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
@ -546,7 +546,7 @@ const dataProvider = {
const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.id}`, {
method: "DELETE",
body: JSON.stringify(params.previousData, filterNullValues),
body: JSON.stringify(params.data, filterNullValues),
}).then(({ json }) => ({
data: json,
}));

View file

@ -1,48 +0,0 @@
import { fetchUtils } from "react-admin";
export const splitMxid = mxid => {
const re =
/^@(?<name>[a-zA-Z0-9._=\-/]+):(?<domain>[a-zA-Z0-9\-.]+\.[a-zA-Z]+)$/;
return re.exec(mxid)?.groups;
};
export const isValidBaseUrl = baseUrl =>
/^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?$/.test(baseUrl);
/**
* Resolve the homeserver URL using the well-known lookup
* @param domain the domain part of an MXID
* @returns homeserver base URL
*/
export const getWellKnownUrl = async domain => {
const wellKnownUrl = `https://${domain}/.well-known/matrix/client`;
try {
const json = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
return json["m.homeserver"].base_url;
} catch {
// if there is no .well-known entry, return the domain itself
return `https://${domain}`;
}
};
/**
* Get synapse server version
* @param base_url the base URL of the homeserver
* @returns server version
*/
export const getServerVersion = async baseUrl => {
const versionUrl = `${baseUrl}/_synapse/admin/v1/server_version`;
const response = await fetchUtils.fetchJson(versionUrl, { method: "GET" });
return response.json.server_version;
};
/**
* Get supported login flows
* @param baseUrl the base URL of the homeserver
* @returns array of supported login flows
*/
export const getSupportedLoginFlows = async baseUrl => {
const loginFlowsUrl = `${baseUrl}/_matrix/client/r0/login`;
const response = await fetchUtils.fetchJson(loginFlowsUrl, { method: "GET" });
return response.json.flows;
};

View file

@ -1,31 +0,0 @@
import { isValidBaseUrl, splitMxid } from "./synapse";
describe("splitMxid", () => {
it("splits valid MXIDs", () =>
expect(splitMxid("@name:domain.tld")).toEqual({
name: "name",
domain: "domain.tld",
}));
it("rejects invalid MXIDs", () => expect(splitMxid("foo")).toBeUndefined());
});
describe("isValidBaseUrl", () => {
it("accepts a http URL", () =>
expect(isValidBaseUrl("http://foo.bar")).toBeTruthy());
it("accepts a https URL", () =>
expect(isValidBaseUrl("https://foo.bar")).toBeTruthy());
it("accepts a valid URL with port", () =>
expect(isValidBaseUrl("https://foo.bar:1234")).toBeTruthy());
it("rejects undefined base URLs", () =>
expect(isValidBaseUrl(undefined)).toBeFalsy());
it("rejects null base URLs", () => expect(isValidBaseUrl(null)).toBeFalsy());
it("rejects empty base URLs", () => expect(isValidBaseUrl("")).toBeFalsy());
it("rejects non-string base URLs", () =>
expect(isValidBaseUrl({})).toBeFalsy());
it("rejects base URLs without protocol", () =>
expect(isValidBaseUrl("foo.bar")).toBeFalsy());
it("rejects base URLs with path", () =>
expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
it("rejects invalid base URLs", () =>
expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
});

7933
yarn.lock

File diff suppressed because it is too large Load diff