mirror of
https://github.com/etkecc/synapse-admin.git
synced 2024-11-21 15:25:22 +03:00
Add option to control user's experimental features (#111)
* Add option to control user's experimental features * Don't use ReferenceManyField, load experimental features manually * cleanup * Move experimental features to their own components, improve UI * remove background from Stack * update readme
This commit is contained in:
parent
86b4987b7f
commit
cd1ca7c039
6 changed files with 130 additions and 8 deletions
|
@ -87,6 +87,7 @@ with a proper manifest.json generation on build)
|
||||||
* [Fix room state events display](https://github.com/etkecc/synapse-admin/pull/100)
|
* [Fix room state events display](https://github.com/etkecc/synapse-admin/pull/100)
|
||||||
* [Sanitize CSV on import](https://github.com/etkecc/synapse-admin/pull/101)
|
* [Sanitize CSV on import](https://github.com/etkecc/synapse-admin/pull/101)
|
||||||
* Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available)
|
* Allow setting version using `SYNAPSE_ADMIN_VERSION` environment variable on build (if git is not available)
|
||||||
|
* [Add option to control user's experimental features](https://github.com/etkecc/synapse-admin/pull/111)
|
||||||
|
|
||||||
_the list will be updated as new changes are added_
|
_the list will be updated as new changes are added_
|
||||||
|
|
||||||
|
|
95
src/components/ExperimentalFeatures.tsx
Normal file
95
src/components/ExperimentalFeatures.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { useRecordContext } from "react-admin";
|
||||||
|
import { useNotify } from "react-admin";
|
||||||
|
import { useDataProvider } from "react-admin";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Stack, Switch, Typography } from "@mui/material";
|
||||||
|
import { ExperimentalFeaturesModel, SynapseDataProvider } from "../synapse/dataProvider";
|
||||||
|
|
||||||
|
const experimentalFeaturesMap = {
|
||||||
|
msc3881: "enable remotely toggling push notifications for another client",
|
||||||
|
msc3575: "enable experimental sliding sync support",
|
||||||
|
};
|
||||||
|
const ExperimentalFeatureRow = (props: { featureKey: string, featureValue: boolean, updateFeature: (feature_name: string, feature_value: boolean) => void}) => {
|
||||||
|
const featureKey = props.featureKey;
|
||||||
|
const featureValue = props.featureValue;
|
||||||
|
const featureDescription = experimentalFeaturesMap[featureKey] ?? "";
|
||||||
|
const [checked, setChecked] = useState(featureValue);
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setChecked(event.target.checked);
|
||||||
|
props.updateFeature(featureKey, event.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
alignItems="start"
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch checked={checked} onChange={handleChange} />
|
||||||
|
<Stack>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "medium",
|
||||||
|
color: "text.primary"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{featureKey}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
{featureDescription}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExperimentalFeaturesList = () => {
|
||||||
|
const record = useRecordContext();
|
||||||
|
const notify = useNotify();
|
||||||
|
const dataProvider = useDataProvider() as SynapseDataProvider;
|
||||||
|
const [features, setFeatures] = useState({});
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFeatures = async () => {
|
||||||
|
const features = await dataProvider.getFeatures(record.id);
|
||||||
|
setFeatures(features);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFeatures();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFeature = async (feature_name: string, feature_value: boolean) => {
|
||||||
|
const updatedFeatures = {...features, [feature_name]: feature_value} as ExperimentalFeaturesModel;
|
||||||
|
setFeatures(updatedFeatures);
|
||||||
|
const reponse = await dataProvider.updateFeatures(record.id, updatedFeatures);
|
||||||
|
notify("ra.notification.updated", {
|
||||||
|
messageArgs: { smart_count: 1 },
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Stack
|
||||||
|
direction="column"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
{Object.keys(features).map((featureKey: string) =>
|
||||||
|
<ExperimentalFeatureRow
|
||||||
|
key={featureKey}
|
||||||
|
featureKey={featureKey}
|
||||||
|
featureValue={features[featureKey]}
|
||||||
|
updateFeature={updateFeature}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ const Footer = () => {
|
||||||
borderColor: '#ddd',
|
borderColor: '#ddd',
|
||||||
p: 1,
|
p: 1,
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2" component="div">
|
||||||
<Avatar src="./images/logo.webp" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />
|
<Avatar src="./images/logo.webp" sx={{ width: "1rem", height: "1rem", display: "inline-block", verticalAlign: "sub" }} />
|
||||||
<Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
|
<Link sx={{ color: "#888", textDecoration: 'none' }} href="https://github.com/etkecc/synapse-admin" target="_blank">
|
||||||
Synapse Admin
|
Synapse Admin
|
||||||
|
|
|
@ -159,7 +159,7 @@ const en: SynapseTranslationMessages = {
|
||||||
erase_avatar: "Erase avatar",
|
erase_avatar: "Erase avatar",
|
||||||
delete_media: "Delete all media uploaded by the user(-s)",
|
delete_media: "Delete all media uploaded by the user(-s)",
|
||||||
redact_events: "Redact all events sent by the user(-s)",
|
redact_events: "Redact all events sent by the user(-s)",
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
name: "Room |||| Rooms",
|
name: "Room |||| Rooms",
|
||||||
|
|
|
@ -7,9 +7,10 @@ import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
import PermMediaIcon from "@mui/icons-material/PermMedia";
|
||||||
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
import PersonPinIcon from "@mui/icons-material/PersonPin";
|
||||||
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
|
||||||
|
import ScienceIcon from "@mui/icons-material/Science";
|
||||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Alert, ownerDocument } from "@mui/material";
|
import { Alert, Switch, Stack, Typography } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
ArrayInput,
|
ArrayInput,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
|
@ -54,10 +55,10 @@ import {
|
||||||
useNotify,
|
useNotify,
|
||||||
Identifier,
|
Identifier,
|
||||||
ToolbarClasses,
|
ToolbarClasses,
|
||||||
RaRecord,
|
|
||||||
ImageInput,
|
ImageInput,
|
||||||
ImageField,
|
ImageField,
|
||||||
FunctionField,
|
FunctionField,
|
||||||
|
useDataProvider,
|
||||||
} from "react-admin";
|
} from "react-admin";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
@ -68,6 +69,7 @@ import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/Server
|
||||||
import { DATE_FORMAT } from "../components/date";
|
import { DATE_FORMAT } from "../components/date";
|
||||||
import { DeviceRemoveButton } from "../components/devices";
|
import { DeviceRemoveButton } from "../components/devices";
|
||||||
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
import { MediaIDField, ProtectMediaButton, QuarantineMediaButton } from "../components/media";
|
||||||
|
import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures";
|
||||||
|
|
||||||
const choices_medium = [
|
const choices_medium = [
|
||||||
{ id: "email", name: "resources.users.email" },
|
{ id: "email", name: "resources.users.email" },
|
||||||
|
@ -126,8 +128,6 @@ const UserBulkActionButtons = () => {
|
||||||
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
|
||||||
const selectedIds = record.selectedIds;
|
const selectedIds = record.selectedIds;
|
||||||
const ownUserId = localStorage.getItem("user_id");
|
const ownUserId = localStorage.getItem("user_id");
|
||||||
const notify = useNotify();
|
|
||||||
const translate = useTranslate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
setOwnUserIsSelected(selectedIds.includes(ownUserId));
|
||||||
|
@ -238,11 +238,11 @@ export const UserCreate = (props: CreateProps) => (
|
||||||
|
|
||||||
const UserTitle = () => {
|
const UserTitle = () => {
|
||||||
const record = useRecordContext();
|
const record = useRecordContext();
|
||||||
|
const translate = useTranslate();
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const translate = useTranslate();
|
|
||||||
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
|
||||||
if (isASManaged(record?.id)) {
|
if (isASManaged(record?.id)) {
|
||||||
username += " 🤖";
|
username += " 🤖";
|
||||||
|
@ -314,7 +314,11 @@ export const UserEdit = (props: EditProps) => {
|
||||||
const translate = useTranslate();
|
const translate = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
|
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic" queryOptions={{
|
||||||
|
meta: {
|
||||||
|
include: ["features"] // Tell your dataProvider to include features
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<TabbedForm toolbar={<UserEditToolbar />}>
|
<TabbedForm toolbar={<UserEditToolbar />}>
|
||||||
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
|
||||||
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
<AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
|
||||||
|
@ -448,6 +452,10 @@ export const UserEdit = (props: EditProps) => {
|
||||||
</Datagrid>
|
</Datagrid>
|
||||||
</ReferenceManyField>
|
</ReferenceManyField>
|
||||||
</FormTab>
|
</FormTab>
|
||||||
|
|
||||||
|
<FormTab label="Experimental" icon={<ScienceIcon />} path="experimental">
|
||||||
|
<ExperimentalFeaturesList />
|
||||||
|
</FormTab>
|
||||||
</TabbedForm>
|
</TabbedForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
|
|
|
@ -248,9 +248,16 @@ export interface UploadMediaResult {
|
||||||
content_uri: string;
|
content_uri: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExperimentalFeaturesModel {
|
||||||
|
features: {
|
||||||
|
[key: string]: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface SynapseDataProvider extends DataProvider {
|
export interface SynapseDataProvider extends DataProvider {
|
||||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||||
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
uploadMedia: (params: UploadMediaParams) => Promise<UploadMediaResult>;
|
||||||
|
updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceMap = {
|
const resourceMap = {
|
||||||
|
@ -798,6 +805,17 @@ const baseDataProvider: SynapseDataProvider = {
|
||||||
});
|
});
|
||||||
return json as UploadMediaResult;
|
return json as UploadMediaResult;
|
||||||
},
|
},
|
||||||
|
getFeatures: async (id: Identifier) => {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||||
|
const { json } = await jsonClient(endpoint_url);
|
||||||
|
return json.features as ExperimentalFeaturesModel;
|
||||||
|
},
|
||||||
|
updateFeatures: async (id: Identifier, features: ExperimentalFeaturesModel) => {
|
||||||
|
const base_url = storage.getItem("base_url");
|
||||||
|
const endpoint_url = `${base_url}/_synapse/admin/v1/experimental_features/${encodeURIComponent(returnMXID(id))}`;
|
||||||
|
await jsonClient(endpoint_url, { method: "PUT", body: JSON.stringify({ features }) });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||||
|
|
Loading…
Reference in a new issue