Allow server admin to create rooms for other users and change user power levels

Change-Id: Ie96e9e0102454835536b6f42d247f9e714e28480
This commit is contained in:
Elshad Shirinov 2020-11-12 14:56:17 +01:00
parent 931fafc21d
commit 270d48607a
5 changed files with 333 additions and 10 deletions

View File

@ -4,7 +4,7 @@ import polyglotI18nProvider from "ra-i18n-polyglot";
import authProvider from "./synapse/authProvider"; import authProvider from "./synapse/authProvider";
import dataProvider from "./synapse/dataProvider"; import dataProvider from "./synapse/dataProvider";
import { UserList, UserCreate, UserEdit } from "./components/users"; import { UserList, UserCreate, UserEdit } from "./components/users";
import { RoomList, RoomCreate, RoomShow } from "./components/rooms"; import { RoomList, RoomCreate, RoomShow, RoomEdit } from "./components/rooms";
import LoginPage from "./components/LoginPage"; import LoginPage from "./components/LoginPage";
import UserIcon from "@material-ui/icons/Group"; import UserIcon from "@material-ui/icons/Group";
import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList"; import { ViewListIcon as RoomIcon } from "@material-ui/icons/ViewList";
@ -45,6 +45,7 @@ const App = () => (
list={RoomList} list={RoomList}
create={RoomCreate} create={RoomCreate}
show={RoomShow} show={RoomShow}
edit={RoomEdit}
icon={RoomIcon} icon={RoomIcon}
/> />
<Resource name="connections" /> <Resource name="connections" />

View File

@ -1,10 +1,14 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Route, Link } from "react-router-dom";
import { import {
AutocompleteArrayInput, AutocompleteArrayInput,
AutocompleteInput,
BooleanInput, BooleanInput,
BooleanField, BooleanField,
Button,
Create, Create,
Edit,
Datagrid, Datagrid,
Filter, Filter,
FormTab, FormTab,
@ -12,24 +16,39 @@ import {
Pagination, Pagination,
ReferenceArrayInput, ReferenceArrayInput,
ReferenceField, ReferenceField,
ReferenceInput,
ReferenceManyField, ReferenceManyField,
SelectField, SelectField,
Show, Show,
SimpleForm,
Tab, Tab,
TabbedForm, TabbedForm,
TabbedShowLayout, TabbedShowLayout,
TextField, TextField,
TextInput, TextInput,
Toolbar,
useDataProvider,
useRefresh,
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import get from "lodash/get"; import get from "lodash/get";
import { Tooltip, Typography, Chip } from "@material-ui/core"; import {
Tooltip,
Typography,
Chip,
Drawer,
styled,
withStyles,
Select,
MenuItem,
} from "@material-ui/core";
import HttpsIcon from "@material-ui/icons/Https"; import HttpsIcon from "@material-ui/icons/Https";
import NoEncryptionIcon from "@material-ui/icons/NoEncryption"; import NoEncryptionIcon from "@material-ui/icons/NoEncryption";
import PageviewIcon from "@material-ui/icons/Pageview"; import PageviewIcon from "@material-ui/icons/Pageview";
import UserIcon from "@material-ui/icons/Group"; import UserIcon from "@material-ui/icons/Group";
import ViewListIcon from "@material-ui/icons/ViewList"; import ViewListIcon from "@material-ui/icons/ViewList";
import VisibilityIcon from "@material-ui/icons/Visibility"; import VisibilityIcon from "@material-ui/icons/Visibility";
import ContentSave from "@material-ui/icons/Save";
const RoomPagination = props => ( const RoomPagination = props => (
<Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} /> <Pagination {...props} rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />
@ -61,12 +80,13 @@ const EncryptionField = ({ source, record = {}, emptyText }) => {
); );
}; };
const validateDisplayName = fieldval => const validateDisplayName = fieldval => {
fieldval === undefined return fieldval == null
? "synapseadmin.rooms.room_name_required" ? "synapseadmin.rooms.room_name_required"
: fieldval.length === 0 : fieldval.length === 0
? "synapseadmin.rooms.room_name_required" ? "synapseadmin.rooms.room_name_required"
: undefined; : undefined;
};
function approximateAliasLength(alias, homeserver) { function approximateAliasLength(alias, homeserver) {
/* TODO maybe handle punycode in homeserver name */ /* TODO maybe handle punycode in homeserver name */
@ -141,6 +161,16 @@ export const RoomCreate = props => (
validate={validateAlias} validate={validateAlias}
placeholder="#" placeholder="#"
/> />
<ReferenceInput
reference="users"
source="owner"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceInput>
<BooleanInput source="public" label="synapseadmin.rooms.make_public" /> <BooleanInput source="public" label="synapseadmin.rooms.make_public" />
<BooleanInput <BooleanInput
source="encrypt" source="encrypt"
@ -176,6 +206,242 @@ const RoomTitle = ({ record }) => {
); );
}; };
// Explicitely passing "to" prop
// Toolbar adds all kinds of unsupported props to its children :(
const StyledLink = styles => {
const Styled = styled(Link)(styles);
return ({ to, children }) => <Styled to={to}>{children}</Styled>;
};
const RoomMemberEditToolbar = ({ backLink, translate, onSave, ...props }) => {
const SaveLink = StyledLink({
textDecoration: "none",
});
const CancelLink = StyledLink({
textDecoration: "none",
marginLeft: "1em",
});
const SaveIcon = styled(ContentSave)({
width: "1rem",
marginRight: "0.25em",
});
return (
<Toolbar {...props}>
<SaveLink to={backLink}>
<Button onClick={onSave} variant="contained">
<React.Fragment>
<SaveIcon />
{translate("ra.action.save")}
</React.Fragment>
</Button>
</SaveLink>
<CancelLink to={backLink}>
<Button>
<React.Fragment>{translate("ra.action.cancel")}</React.Fragment>
</Button>
</CancelLink>
</Toolbar>
);
};
const RoomMemberIdField = ({ memberId, data = {} }) => {
const value = get(data[memberId], "id");
return (
<Typography component="span" variant="body2">
{value}
</Typography>
);
};
const RoomMemberRoleInput = ({ memberId, data = {}, translate, onChange }) => {
const roleValue = get(data[memberId], "role");
const [role, setRole] = React.useState(roleValue);
React.useEffect(() => {
onChange(roleValue);
}, [onChange, roleValue]);
return (
<React.Fragment>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={role}
onChange={event => {
setRole(event.target.value);
onChange(event.target.value);
}}
>
<MenuItem value={"user"}>
{translate("resources.users.roles.user")}
</MenuItem>
<MenuItem value={"mod"}>
{translate("resources.users.roles.mod")}
</MenuItem>
<MenuItem value={"admin"}>
{translate("resources.users.roles.admin")}
</MenuItem>
</Select>
</React.Fragment>
);
};
const RoomMemberEdit = ({ backLink, memberId, ...props }) => {
const translate = useTranslate();
const refresh = useRefresh();
const dataProvider = useDataProvider();
const [role, setRole] = React.useState();
const { id } = props;
return (
<Edit title=" " {...props}>
<SimpleForm
toolbar={
<RoomMemberEditToolbar
backLink={backLink}
translate={translate}
onSave={() => {
dataProvider
.update("rooms", {
data: {
id,
member_roles: [{ member_id: memberId, role }],
},
})
.then(() => {
refresh();
});
}}
/>
}
>
<ReferenceManyField
reference="room_members"
target="room_id"
label="resources.users.fields.id"
>
<RoomMemberIdField memberId={memberId} />
</ReferenceManyField>
<ReferenceManyField
reference="room_members"
target="room_id"
label="resources.users.fields.role"
>
<RoomMemberRoleInput
memberId={memberId}
translate={translate}
onChange={setRole}
/>
</ReferenceManyField>
</SimpleForm>
</Edit>
);
};
const drawerStyles = {
paper: {
width: 300,
},
};
const StyledDrawer = withStyles(drawerStyles)(({ classes, ...props }) => (
<Drawer {...props} classes={classes} />
));
export const RoomEdit = props => {
const translate = useTranslate();
return (
<React.Fragment>
<Edit {...props} title={<RoomTitle />}>
<TabbedForm>
<FormTab label="synapseadmin.rooms.tabs.members" icon={<UserIcon />}>
<ReferenceArrayInput
reference="users"
source="invitees"
filterToQuery={searchText => ({ user_id: searchText })}
>
<AutocompleteArrayInput
optionText="displayname"
suggestionText="displayname"
/>
</ReferenceArrayInput>
<ReferenceManyField
reference="room_members"
target="room_id"
addLabel={false}
>
<Datagrid
style={{ width: "100%" }}
rowClick={(id, basePath, record) =>
`/rooms/${encodeURIComponent(
record.parentId
)}/${encodeURIComponent(id)}`
}
>
<TextField
source="id"
sortable={false}
label="resources.users.fields.id"
/>
<ReferenceField
label="resources.users.fields.displayname"
source="id"
reference="users"
sortable={false}
link=""
>
<TextField source="displayname" sortable={false} />
</ReferenceField>
<SelectField
source="role"
label="resources.users.fields.role"
choices={[
{
id: "user",
name: translate("resources.users.roles.user"),
},
{ id: "mod", name: translate("resources.users.roles.mod") },
{
id: "admin",
name: translate("resources.users.roles.admin"),
},
]}
/>
</Datagrid>
</ReferenceManyField>
</FormTab>
</TabbedForm>
</Edit>
<Route path="/rooms/:roomId/:memberId">
{({ match }) => {
const isMatch = !!match && !!match.params;
return (
<StyledDrawer open={isMatch} anchor="right">
{isMatch ? (
<RoomMemberEdit
{...props}
memberId={
isMatch ? decodeURIComponent(match.params.memberId) : null
}
backLink={`/rooms/${match.params.roomId}`}
/>
) : (
<div />
)}
</StyledDrawer>
);
}}
</Route>
</React.Fragment>
);
};
export const RoomShow = props => { export const RoomShow = props => {
const translate = useTranslate(); const translate = useTranslate();
return ( return (
@ -227,6 +493,18 @@ export const RoomShow = props => {
> >
<TextField source="displayname" sortable={false} /> <TextField source="displayname" sortable={false} />
</ReferenceField> </ReferenceField>
<SelectField
source="role"
label="resources.users.fields.role"
choices={[
{ id: "user", name: translate("resources.users.roles.user") },
{ id: "mod", name: translate("resources.users.roles.mod") },
{
id: "admin",
name: translate("resources.users.roles.admin"),
},
]}
/>
</Datagrid> </Datagrid>
</ReferenceManyField> </ReferenceManyField>
</Tab> </Tab>

View File

@ -70,6 +70,7 @@ export default {
display_name: "Gerätename", display_name: "Gerätename",
last_seen_ts: "Zeitstempel", last_seen_ts: "Zeitstempel",
last_seen_ip: "IP-Adresse", last_seen_ip: "IP-Adresse",
role: "Rolle",
}, },
type: { type: {
default: "Standard", default: "Standard",
@ -83,6 +84,11 @@ export default {
action: { action: {
erase: "Lösche Benutzerdaten", erase: "Lösche Benutzerdaten",
}, },
roles: {
user: "Nutzer",
mod: "Moderator",
admin: "Administrator",
},
}, },
rooms: { rooms: {
name: "Raum |||| Räume", name: "Raum |||| Räume",

View File

@ -69,6 +69,7 @@ export default {
display_name: "Device name", display_name: "Device name",
last_seen_ts: "Timestamp", last_seen_ts: "Timestamp",
last_seen_ip: "IP address", last_seen_ip: "IP address",
role: "Role",
}, },
type: { type: {
default: "Standard", default: "Standard",
@ -82,6 +83,11 @@ export default {
action: { action: {
erase: "Erase user data", erase: "Erase user data",
}, },
roles: {
user: "User",
mod: "Moderator",
admin: "Administrator",
},
}, },
rooms: { rooms: {
name: "Room |||| Rooms", name: "Room |||| Rooms",

View File

@ -25,6 +25,16 @@ const mxcUrlToHttp = mxcUrl => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`; return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
}; };
const powerLevelToRole = powerLevel =>
powerLevel < 100 ? (powerLevel < 50 ? "user" : "mod") : "admin";
const POWER_LEVELS = {
admin: 100,
mod: 50,
user: 0,
};
const roleToPowerLevel = role => POWER_LEVELS[role] || 0;
const resourceMap = { const resourceMap = {
users: { users: {
path: "/_synapse/admin/v2/users", path: "/_synapse/admin/v2/users",
@ -66,8 +76,9 @@ const resourceMap = {
data: "rooms", data: "rooms",
total: json => json.total_rooms, total: json => json.total_rooms,
create: data => ({ create: data => ({
endpoint: "/_matrix/client/r0/createRoom", endpoint: "/_synapse/admin/v1/rooms",
body: { body: {
owner: data.owner,
name: data.name, name: data.name,
room_alias_name: data.canonical_alias, room_alias_name: data.canonical_alias,
visibility: data.public ? "public" : "private", visibility: data.public ? "public" : "private",
@ -89,6 +100,20 @@ const resourceMap = {
}, },
method: "POST", method: "POST",
}), }),
delete: params => ({
endpoint: `/_synapse/admin/v1/rooms/${params.id}/delete`,
body: { erase: true },
method: "POST",
}),
transformBeforeUpdate: data => {
return {
...data,
member_roles: (data.member_roles || []).map(member => ({
member_id: member.member_id,
power_level: roleToPowerLevel(member.role),
})),
};
},
}, },
devices: { devices: {
map: d => ({ map: d => ({
@ -113,10 +138,11 @@ const resourceMap = {
}, },
room_members: { room_members: {
map: m => ({ map: m => ({
id: m, role: powerLevelToRole(m.power_level),
id: m.user_id,
}), }),
reference: id => ({ reference: id => ({
endpoint: `/_synapse/admin/v1/rooms/${id}/members`, endpoint: `/_synapse/admin/v1/rooms/${id}/power_levels`,
}), }),
data: "members", data: "members",
}, },
@ -183,7 +209,7 @@ const dataProvider = {
}, },
getOne: (resource, params) => { getOne: (resource, params) => {
console.log("getOne " + resource); console.log("getOne " + resource, params);
const homeserver = localStorage.getItem("base_url"); const homeserver = localStorage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) return Promise.reject(); if (!homeserver || !(resource in resourceMap)) return Promise.reject();
@ -222,7 +248,10 @@ const dataProvider = {
const endpoint_url = homeserver + ref.endpoint; const endpoint_url = homeserver + ref.endpoint;
return jsonClient(endpoint_url).then(({ headers, json }) => ({ return jsonClient(endpoint_url).then(({ headers, json }) => ({
data: json[res.data].map(res.map), data: json[res.data].map(res.map).map(element => ({
...element,
parentId: params.id,
})),
})); }));
}, },
@ -233,10 +262,13 @@ const dataProvider = {
const res = resourceMap[resource]; const res = resourceMap[resource];
const transform = res.transformBeforeUpdate || (x => x);
const data = transform(params.data);
const endpoint_url = homeserver + res.path; const endpoint_url = homeserver + res.path;
return jsonClient(`${endpoint_url}/${params.data.id}`, { return jsonClient(`${endpoint_url}/${params.data.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(params.data, filterNullValues), body: JSON.stringify(data, filterNullValues),
}).then(({ json }) => ({ }).then(({ json }) => ({
data: res.map(json), data: res.map(json),
})); }));