Compare commits

8 Commits

Author SHA1 Message Date
Borislav Pantaleev
31d3712dbb Better media preview/download implementation (#53)
* Add download option for non image media

* make view media button change the icon during the loading; update readme
2024-10-03 11:23:51 +03:00
Aine
a15dad4a31 ensure preview works for images only (for now) 2024-10-03 01:18:45 +03:00
Borislav Pantaleev
9062a6afb1 Remove Dialog window for image preview, open all media in new tab 2024-10-03 00:52:07 +03:00
Borislav Pantaleev
a79c3597d6 Authenticated Media Support (#51)
* Fix AvatarField to work with authenticated media

* Fix ViewMediaButton to work for user's media tab and reported events

* remove console.log

* cleanup AvatarField

* use correct thumbnail size

* fix AvatarField.test

* ignore postgres data for watchman

* fix new avatar preview

* watchman should ignore testdata completely, instead of specific subdirs

* update README

* change user's media icon in sidebar - use the same icon as the media tab

* Add preview for user media files if mimeType is image/*

* Add new line in user media Dialog
2024-10-03 00:38:35 +03:00
Aine
470f1b5455 adjust 'redact all user events on remove' to the new API 2024-10-02 23:34:07 +03:00
Aine
46cc0e2fda refactor users' after delete to react-admin lifecycle hooks, fixes #50 2024-09-29 19:46:00 +03:00
Aine
f3080e9468 Provide options to delete media and redact events on user erase (#49)
* Provide options to delete media and redact events on user erase

* update readme
2024-09-29 00:16:44 +03:00
Aine
f8fe1166e2 Do not block deleted rooms from being rejoined by default (UI option), fixes #48 2024-09-27 19:25:15 +03:00
24 changed files with 442 additions and 103 deletions

5
.watchmanconfig Normal file
View File

@@ -0,0 +1,5 @@
{
"ignore_dirs": [
"testdata"
]
}

View File

@@ -60,6 +60,9 @@ The following changes are already implemented:
* [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40) * [Upgrade react-admin to v5](https://github.com/etkecc/synapse-admin/pull/40)
* [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42) * [Restrict actions on specific users](https://github.com/etkecc/synapse-admin/pull/42)
* [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45) * [Add `Contact support` menu item](https://github.com/etkecc/synapse-admin/pull/45)
* [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49)
* [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51)
* [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53)
_the list will be updated as new changes are added_ _the list will be updated as new changes are added_
@@ -136,7 +139,7 @@ Synapse-Admin provides a support link in the main menu - `Contact support`. By d
### Supported Synapse ### Supported Synapse
It needs at least [Synapse](https://github.com/element-hq/synapse) v1.93.0 for all functions to work as expected! It needs at least [Synapse](https://github.com/element-hq/synapse) v1.116.0 for all functions to work as expected!
You get your server version with the request `/_synapse/admin/v1/server_version`. 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://element-hq.github.io/synapse/latest/admin_api/version_api.html).

View File

@@ -1,18 +1,43 @@
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { RecordContextProvider } from "react-admin"; import { RecordContextProvider } from "react-admin";
import { act } from "react";
import AvatarField from "./AvatarField"; import AvatarField from "./AvatarField";
describe("AvatarField", () => { describe("AvatarField", () => {
it("shows image", () => { beforeEach(() => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.resolve(new Blob(["mock image data"], { type: 'image/jpeg' })),
})
) as jest.Mock;
// Mock URL.createObjectURL
global.URL.createObjectURL = jest.fn(() => "mock-object-url");
});
afterEach(() => {
jest.restoreAllMocks();
});
it.only("shows image", async () => {
const value = { const value = {
avatar: "foo", avatar: "mxc://serverName/mediaId",
}; };
render(
<RecordContextProvider value={value}> await act(async () => {
<AvatarField source="avatar" /> render(
</RecordContextProvider> <RecordContextProvider value={value}>
); <AvatarField source="avatar" />
expect(screen.getByRole("img").getAttribute("src")).toBe("foo"); </RecordContextProvider>
);
});
await waitFor(() => {
const img = screen.getByRole("img");
expect(img.getAttribute("src")).toBe("mock-object-url");
});
expect(global.fetch).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,12 +1,37 @@
import { get } from "lodash"; import { get } from "lodash";
import { Avatar, AvatarProps } from "@mui/material";
import { Avatar } from "@mui/material";
import { useRecordContext } from "react-admin"; import { useRecordContext } from "react-admin";
import { useState, useEffect, useCallback } from "react";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
const AvatarField = ({ source, ...rest }) => { const AvatarField = ({ source, ...rest }: AvatarProps & { source: string, label?: string }) => {
const record = useRecordContext(rest);
const src = get(record, source)?.toString();
const { alt, classes, sizes, sx, variant } = rest; const { alt, classes, sizes, sx, variant } = rest;
const record = useRecordContext(rest);
const mxcURL = get(record, source)?.toString();
const [src, setSrc] = useState<string>("");
const fetchAvatar = useCallback(async (mxcURL: string) => {
const response = await fetchAuthenticatedMedia(mxcURL, "thumbnail");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
setSrc(blobURL);
}, []);
useEffect(() => {
if (mxcURL) {
fetchAvatar(mxcURL);
}
// Cleanup function to revoke the object URL
return () => {
if (src) {
URL.revokeObjectURL(src);
}
};
}, [mxcURL, fetchAvatar]);
return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />; return <Avatar alt={alt} classes={classes} sizes={sizes} src={src} sx={sx} variant={variant} />;
}; };

View File

@@ -16,7 +16,7 @@ const resourceName = "rooms";
const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => { const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
const translate = useTranslate(); const translate = useTranslate();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [block, setBlock] = useState(true); const [block, setBlock] = useState(false);
const notify = useNotify(); const notify = useNotify();
const redirect = useRedirect(); const redirect = useRedirect();
@@ -78,7 +78,7 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
value={block} value={block}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}
label="resources.rooms.action.erase.fields.block" label="resources.rooms.action.erase.fields.block"
defaultValue={true} defaultValue={false}
/> />
</SimpleForm> </SimpleForm>
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,117 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { Fragment, useState } from "react";
import { SimpleForm, BooleanInput, useTranslate, RaRecord, useNotify, useRedirect, useDelete, NotificationType, useDeleteMany, Identifier, useUnselectAll } from "react-admin";
import ActionDelete from "@mui/icons-material/Delete";
import ActionCheck from "@mui/icons-material/CheckCircle";
import AlertError from "@mui/icons-material/ErrorOutline";
interface DeleteUserButtonProps {
selectedIds: Identifier[];
confirmTitle: string;
confirmContent: string;
}
const resourceName = "users";
const DeleteUserButton: React.FC<DeleteUserButtonProps> = (props) => {
const translate = useTranslate();
const [open, setOpen] = useState(false);
const [deleteMedia, setDeleteMedia] = useState(false);
const [redactEvents, setRedactEvents] = useState(false);
const notify = useNotify();
const redirect = useRedirect();
const [deleteMany, { isLoading }] = useDeleteMany();
const unselectAll = useUnselectAll(resourceName);
const recordIds = props.selectedIds;
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
const handleDelete = (values: {deleteMedia: boolean, redactEvents: boolean}) => {
deleteMany(
resourceName,
{ ids: recordIds, meta: values },
{
onSuccess: () => {
notify("ra.notification.deleted", {
messageArgs: {
smart_count: recordIds.length,
},
type: 'info' as NotificationType,
});
handleDialogClose();
unselectAll();
redirect("/users");
},
onError: (error) =>
notify("ra.notification.data_provider_error", { type: 'error' as NotificationType }),
}
);
};
const handleConfirm = () => {
setOpen(false);
handleDelete({ deleteMedia: deleteMedia, redactEvents: redactEvents });
};
return (
<Fragment>
<Button
onClick={handleDialogOpen}
disabled={isLoading}
className={"ra-delete-button"}
key="button"
size="small"
sx={{
"&.MuiButton-sizeSmall": {
lineHeight: 1.5,
},
}}
color={"error"}
startIcon={<ActionDelete />}
>
{translate("ra.action.delete")}
</Button>
<Dialog open={open} onClose={handleDialogClose}>
<DialogTitle>{translate(props.confirmTitle)}</DialogTitle>
<DialogContent>
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
<SimpleForm toolbar={false}>
<BooleanInput
source="deleteMedia"
value={deleteMedia}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDeleteMedia(event.target.checked)}
label="resources.users.action.delete_media"
defaultValue={false}
/>
<BooleanInput
source="redactEvents"
value={redactEvents}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setRedactEvents(event.target.checked)}
label="resources.users.action.redact_events"
defaultValue={false}
/>
</SimpleForm>
</DialogContent>
<DialogActions>
<Button disabled={false} onClick={handleDialogClose} startIcon={<AlertError />}>
{translate("ra.action.cancel")}
</Button>
<Button
disabled={false}
onClick={handleConfirm}
className={"ra-confirm RaConfirm-confirmPrimary"}
autoFocus
startIcon={<ActionCheck />}
>
{translate("ra.action.confirm")}
</Button>
</DialogActions>
</Dialog>
</Fragment>
);
};
export default DeleteUserButton;

View File

@@ -8,7 +8,9 @@ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import FileOpenIcon from "@mui/icons-material/FileOpen"; import FileOpenIcon from "@mui/icons-material/FileOpen";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockOpenIcon from "@mui/icons-material/LockOpen";
import { Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip } from "@mui/material"; import DownloadIcon from '@mui/icons-material/Download';
import DownloadingIcon from '@mui/icons-material/Downloading';
import { Grid2 as Grid, Box, Dialog, DialogContent, DialogContentText, DialogTitle, Tooltip, Link } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
import { import {
BooleanInput, BooleanInput,
@@ -29,12 +31,11 @@ import {
useTranslate, useTranslate,
} from "react-admin"; } from "react-admin";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { dateParser } from "./date"; import { dateParser } from "./date";
import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider"; import { DeleteMediaParams, SynapseDataProvider } from "../synapse/dataProvider";
import { getMediaUrl } from "../synapse/synapse";
import storage from "../storage"; import storage from "../storage";
import { fetchAuthenticatedMedia } from "../utils/fetchMedia";
const DeleteMediaDialog = ({ open, onClose, onSubmit }) => { const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate(); const translate = useTranslate();
@@ -311,48 +312,104 @@ export const QuarantineMediaButton = (props: ButtonProps) => {
); );
}; };
export const ViewMediaButton = ({ media_id, label }) => { export const ViewMediaButton = ({ mxcURL, label, uploadName, mimetype }) => {
const translate = useTranslate(); const translate = useTranslate();
const url = getMediaUrl(media_id); const [loading, setLoading] = useState(false);
const isImage = mimetype && mimetype.startsWith("image/");
const openFileInNewTab = (blobURL: string) => {
const anchorElement = document.createElement("a");
anchorElement.href = blobURL;
anchorElement.target = "_blank";
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
setTimeout(() => URL.revokeObjectURL(blobURL), 10);
};
const downloadFile = async (blobURL: string) => {
console.log("downloadFile", blobURL, uploadName);
const anchorElement = document.createElement("a");
anchorElement.href = blobURL;
anchorElement.download = uploadName;
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
setTimeout(() => URL.revokeObjectURL(blobURL), 10);;
};
const handleFile = async (preview: boolean) => {
setLoading(true);
const response = await fetchAuthenticatedMedia(mxcURL, "original");
const blob = await response.blob();
const blobURL = URL.createObjectURL(blob);
if (preview) {
openFileInNewTab(blobURL);
} else {
downloadFile(blobURL);
}
setLoading(false);
};
return ( return (
<Box style={{ whiteSpace: "pre" }}> <>
<Tooltip title={translate("resources.users_media.action.open")}> <Box display="flex" alignItems="center">
<span> <Tooltip title={translate("resources.users_media.action.open")}>
<Button <span>
component={Link} {isImage && (
to={url} <Button
target="_blank" disabled={loading}
rel="noopener" onClick={() => handleFile(true)}
style={{ minWidth: 0, paddingLeft: 0, paddingRight: 0 }} style={{ minWidth: 0, padding: 0, marginRight: 8 }}
> >
<FileOpenIcon /> {loading ? <DownloadingIcon /> : <FileOpenIcon />}
</Button> </Button>
</span> )}
</Tooltip> </span>
{label} </Tooltip>
</Box> <Button
disabled={loading}
onClick={() => handleFile(false)}
style={{ minWidth: 0, padding: 0, marginRight: 8 }}
>
{loading ? <DownloadingIcon /> : <DownloadIcon />}
</Button>
<span>{label}</span>
</Box>
</>
); );
}; };
export const MediaIDField = ({ source }) => { export const MediaIDField = ({ source }) => {
const record = useRecordContext();
if (!record) {
return null;
}
const homeserver = storage.getItem("home_server"); const homeserver = storage.getItem("home_server");
const record = useRecordContext();
if (!record) return null;
const src = get(record, source)?.toString(); const mediaID = get(record, source)?.toString();
if (!src) return null; if (!mediaID) {
return null;
}
return <ViewMediaButton media_id={`${homeserver}/${src}`} label={src} />; const uploadName = decodeURIComponent(get(record, "upload_name")?.toString());
const mxcURL = `mxc://${homeserver}/${mediaID}`;
return <ViewMediaButton mxcURL={mxcURL} label={mediaID} uploadName={uploadName} mimetype={record.media_type}/>;
}; };
export const MXCField = ({ source }) => { export const ReportMediaContent = ({ source }) => {
const record = useRecordContext(); const record = useRecordContext();
if (!record) return null; if (!record) {
return null;
}
const src = get(record, source)?.toString(); const mxcURL = get(record, source)?.toString();
if (!src) return null; if (!mxcURL) {
return null;
}
const media_id = src.replace("mxc://", ""); const uploadName = decodeURIComponent(get(record, "event_json.content.body")?.toString());
return <ViewMediaButton media_id={media_id} label={src} />; return <ViewMediaButton mxcURL={mxcURL} label={mxcURL} uploadName={uploadName} mimetype={record.media_type}/>;
}; };

View File

@@ -151,12 +151,15 @@ const de: SynapseTranslationMessages = {
password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.", password: "Durch die Änderung des Passworts wird der Benutzer von allen Sitzungen abgemeldet.",
deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.", deactivate: "Sie müssen ein Passwort angeben, um ein Konto wieder zu aktivieren.",
erase: "DSGVO konformes Löschen der Benutzerdaten.", erase: "DSGVO konformes Löschen der Benutzerdaten.",
erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.",
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.", erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt.",
modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.", modify_managed_user_error: "Das Ändern eines vom System verwalteten Benutzers ist nicht zulässig.",
}, },
action: { action: {
erase: "Lösche Benutzerdaten", erase: "Lösche Benutzerdaten",
erase_avatar: "Avatar löschen" erase_avatar: "Avatar löschen",
delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen",
redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)",
}, },
}, },
rooms: { rooms: {

View File

@@ -142,12 +142,15 @@ const en: SynapseTranslationMessages = {
password: "Changing password will log user out of all sessions.", password: "Changing password will log user out of all sessions.",
deactivate: "You must provide a password to re-activate an account.", deactivate: "You must provide a password to re-activate an account.",
erase: "Mark the user as GDPR-erased", erase: "Mark the user as GDPR-erased",
erase_text: "This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.",
erase_admin_error: "Deleting own user is not allowed.", erase_admin_error: "Deleting own user is not allowed.",
modify_managed_user_error: "Modifying a system-managed user is not allowed.", modify_managed_user_error: "Modifying a system-managed user is not allowed.",
}, },
action: { action: {
erase: "Erase user data", erase: "Erase user data",
erase_avatar: "Erase avatar" erase_avatar: "Erase avatar",
delete_media: "Delete all media uploaded by the user(-s)",
redact_events: "Redact all events sent by the user(-s)",
}, },
}, },
rooms: { rooms: {

View File

@@ -138,11 +138,15 @@ const fa: SynapseTranslationMessages = {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.", password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.", deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید", erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.",
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.", erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.", modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
}, },
action: { action: {
erase: "پاک کردن اطلاعات کاربر", erase: "پاک کردن اطلاعات کاربر",
erase_avatar: "محو الصورة الرمزية",
delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)",
redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)",
}, },
}, },
rooms: { rooms: {

View File

@@ -140,12 +140,15 @@ const fr: SynapseTranslationMessages = {
helper: { helper: {
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.", deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD", erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.",
erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.", erase_admin_error: "La suppression de son propre utilisateur n'est pas autorisée.",
modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.", modify_managed_user_error: "La modification d'un utilisateur géré par le système n'est pas autorisée.",
}, },
action: { action: {
erase: "Effacer les données de l'utilisateur", erase: "Effacer les données de l'utilisateur",
erase_avatar: "Effacer l'avatar", erase_avatar: "Effacer l'avatar",
delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)",
redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)",
}, },
}, },
rooms: { rooms: {

3
src/i18n/index.d.ts vendored
View File

@@ -138,12 +138,15 @@ interface SynapseTranslationMessages extends TranslationMessages {
password?: string; password?: string;
deactivate: string; deactivate: string;
erase: string; erase: string;
erase_text: string;
erase_admin_error: string; erase_admin_error: string;
modify_managed_user_error: string; modify_managed_user_error: string;
}; };
action: { action: {
erase: string; erase: string;
erase_avatar: string; erase_avatar: string;
delete_media: string;
redact_events: string;
}; };
}; };
rooms: { rooms: {

View File

@@ -139,11 +139,15 @@ const it: SynapseTranslationMessages = {
password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.", password: "Cambiando la password l'utente verrà disconnesso da tutte le sessioni attive.",
deactivate: "Devi fornire una password per riattivare l'account.", deactivate: "Devi fornire una password per riattivare l'account.",
erase: "Constrassegna l'utente come cancellato dal GDPR", erase: "Constrassegna l'utente come cancellato dal GDPR",
erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.",
erase_admin_error: "Non è consentito eliminare il proprio utente.",
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
}, },
action: { action: {
erase: "Cancella i dati dell'utente", erase: "Cancella i dati dell'utente",
erase_admin_error: "Non è consentito eliminare il proprio utente.", erase_avatar: "Cancella l'avatar dell'utente",
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.", delete_media: "Elimina tutti i media caricati dall'utente(-s)",
redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)",
}, },
}, },
rooms: { rooms: {

View File

@@ -159,12 +159,15 @@ const ru: SynapseTranslationMessages = {
password: "Смена пароля завершит все сессии пользователя.", password: "Смена пароля завершит все сессии пользователя.",
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.", deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR", erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.",
erase_admin_error: "Удаление собственного пользователя запрещено.", erase_admin_error: "Удаление собственного пользователя запрещено.",
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.", modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
}, },
action: { action: {
erase: "Удалить данные пользователя", erase: "Удалить данные пользователя",
erase_avatar: "Удалить аватар", erase_avatar: "Удалить аватар",
delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)",
redact_events: "Удаление всех событий, отправленных пользователем (-ами)",
}, },
}, },
rooms: { rooms: {

View File

@@ -143,12 +143,15 @@ const zh: SynapseTranslationMessages = {
helper: { helper: {
deactivate: "您必须提供一串密码来激活账户。", deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了", erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。",
erase_admin_error: "不允许删除自己的用户", erase_admin_error: "不允许删除自己的用户",
modify_managed_user_error: "不允许修改系统管理的用户。", modify_managed_user_error: "不允许修改系统管理的用户。",
}, },
action: { action: {
erase: "抹除用户信息", erase: "抹除用户信息",
erase_avatar: "抹掉头像", erase_avatar: "抹掉头像",
delete_media: "删除用户上传的所有媒体",
redact_events: "重新编辑用户(-s发送的所有事件",
}, },
}, },
rooms: { rooms: {

View File

@@ -22,7 +22,7 @@ import {
} from "react-admin"; } from "react-admin";
import { DATE_FORMAT } from "../components/date"; import { DATE_FORMAT } from "../components/date";
import { MXCField } from "../components/media"; import { ReportMediaContent } from "../components/media";
const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const ReportPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
@@ -62,7 +62,7 @@ export const ReportShow = (props: ShowProps) => {
<TextField source="event_json.content.msgtype" /> <TextField source="event_json.content.msgtype" />
<TextField source="event_json.content.body" /> <TextField source="event_json.content.body" />
<TextField source="event_json.content.info.mimetype" /> <TextField source="event_json.content.info.mimetype" />
<MXCField source="event_json.content.url" /> <ReportMediaContent source="event_json.content.url" />
<TextField source="event_json.content.format" /> <TextField source="event_json.content.format" />
<TextField source="event_json.content.formatted_body" /> <TextField source="event_json.content.formatted_body" />
<TextField source="event_json.content.algorithm" /> <TextField source="event_json.content.algorithm" />

View File

@@ -26,9 +26,9 @@ import {
useUnselectAll, useUnselectAll,
} from "react-admin"; } from "react-admin";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import AvatarField from "../components/AvatarField"; import AvatarField from "../components/AvatarField";
const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />; const RoomDirectoryPagination = () => <Pagination rowsPerPageOptions={[100, 500, 1000, 2000]} />;
export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => { export const RoomDirectoryUnpublishButton = (props: DeleteButtonProps) => {
@@ -144,7 +144,6 @@ export const RoomDirectoryList = () => (
> >
<AvatarField <AvatarField
source="avatar_src" source="avatar_src"
sortable={false}
sx={{ height: "40px", width: "40px" }} sx={{ height: "40px", width: "40px" }}
label="resources.rooms.fields.avatar" label="resources.rooms.fields.avatar"
/> />

View File

@@ -47,6 +47,7 @@ import {
} from "./room_directory"; } from "./room_directory";
import { DATE_FORMAT } from "../components/date"; import { DATE_FORMAT } from "../components/date";
import DeleteRoomButton from "../components/DeleteRoomButton"; import DeleteRoomButton from "../components/DeleteRoomButton";
import AvatarField from "../components/AvatarField";
const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const RoomPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
@@ -90,6 +91,11 @@ export const RoomShow = (props: ShowProps) => {
<Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}> <Show {...props} actions={<RoomShowActions />} title={<RoomTitle />}>
<TabbedShowLayout> <TabbedShowLayout>
<Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}> <Tab label="synapseadmin.rooms.tabs.basic" icon={<ViewListIcon />}>
<AvatarField
source="avatar"
sx={{ height: "120px", width: "120px" }}
label="resources.rooms.fields.avatar"
/>
<TextField source="room_id" /> <TextField source="room_id" />
<TextField source="name" /> <TextField source="name" />
<TextField source="topic" /> <TextField source="topic" />

View File

@@ -1,4 +1,4 @@
import EqualizerIcon from "@mui/icons-material/Equalizer"; import PermMediaIcon from "@mui/icons-material/PermMedia";
import { import {
Datagrid, Datagrid,
ExportButton, ExportButton,
@@ -48,7 +48,7 @@ export const UserMediaStatsList = (props: ListProps) => (
const resource: ResourceProps = { const resource: ResourceProps = {
name: "user_media_statistics", name: "user_media_statistics",
icon: EqualizerIcon, icon: PermMediaIcon,
list: UserMediaStatsList, list: UserMediaStatsList,
}; };

View File

@@ -36,7 +36,6 @@ import {
ResourceProps, ResourceProps,
SearchInput, SearchInput,
SelectInput, SelectInput,
BulkDeleteButton,
DeleteButton, DeleteButton,
maxLength, maxLength,
regex, regex,
@@ -57,10 +56,12 @@ import {
RaRecord, RaRecord,
ImageInput, ImageInput,
ImageField, ImageField,
FunctionField,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField"; import AvatarField from "../components/AvatarField";
import DeleteUserButton from "../components/DeleteUserButton";
import { isASManaged } from "../components/mxid"; import { isASManaged } from "../components/mxid";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices"; import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date"; import { DATE_FORMAT } from "../components/date";
@@ -90,11 +91,6 @@ const UserListActions = () => {
); );
}; };
UserListActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null,
};
const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />; const UserPagination = () => <Pagination rowsPerPageOptions={[10, 25, 50, 100, 500, 1000]} />;
const userFilters = [ const userFilters = [
@@ -141,10 +137,10 @@ const UserBulkActionButtons = () => {
<> <>
<ServerNoticeBulkButton /> <ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BulkDeleteButton <DeleteUserButton
label="resources.users.action.erase" selectedIds={selectedIds}
confirmTitle="resources.users.helper.erase" confirmTitle="resources.users.helper.erase"
mutationMode="pessimistic" confirmContent="resources.users.helper.erase_text"
/> />
</UserPreventSelfDelete> </UserPreventSelfDelete>
</> </>
@@ -165,7 +161,7 @@ export const UserList = (props: ListProps) => (
pagination={<UserPagination />} pagination={<UserPagination />}
> >
<Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}> <Datagrid rowClick={usersRowClick} bulkActionButtons={<UserBulkActionButtons />}>
<AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} sortBy="avatar_url" /> <AvatarField source="avatar_src" sx={{ height: "40px", width: "40px" }} />
<TextField source="id" sortBy="name" /> <TextField source="id" sortBy="name" />
<TextField source="displayname" /> <TextField source="displayname" />
<BooleanField source="is_guest" /> <BooleanField source="is_guest" />
@@ -200,15 +196,15 @@ const UserEditActions = () => {
return ( return (
<TopToolbar> <TopToolbar>
{!record?.deactivated && <ServerNoticeButton />} {!record?.deactivated && <ServerNoticeButton />}
{record && record.id && (
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton <DeleteUserButton
label="resources.users.action.erase" selectedIds={[record?.id]}
confirmTitle={translate("resources.users.helper.erase", { confirmTitle="resources.users.helper.erase"
smart_count: 1, confirmContent="resources.users.helper.erase_text"
})}
mutationMode="pessimistic"
/> />
</UserPreventSelfDelete> </UserPreventSelfDelete>
)}
</TopToolbar> </TopToolbar>
); );
}; };
@@ -323,14 +319,19 @@ export const UserEdit = (props: EditProps) => {
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic"> <Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<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" sortable={false} sx={{ height: "120px", width: "120px" }} /> <AvatarField source="avatar_src" sx={{ height: "120px", width: "120px" }} />
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" /> <BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
<ImageInput <ImageInput
source="avatar_file" source="avatar_file"
label="resources.users.fields.avatar" label="resources.users.fields.avatar"
accept={{ "image/*": [".png", ".jpg"] }} accept={{ "image/*": [".png", ".jpg"] }}
> >
<ImageField source="src" title="Avatar" /> <ImageField source="src" title="Avatar" sx={{ '& img': {
width: "120px !important",
height: "120px !important",
objectFit: "cover !important",
borderRadius: '50% !important',
}}} />
</ImageInput> </ImageInput>
<TextInput source="id" readOnly /> <TextInput source="id" readOnly />
<TextInput source="displayname" /> <TextInput source="displayname" />
@@ -404,8 +405,8 @@ export const UserEdit = (props: EditProps) => {
<DateField source="created_ts" showTime options={DATE_FORMAT} /> <DateField source="created_ts" showTime options={DATE_FORMAT} />
<DateField source="last_access_ts" showTime options={DATE_FORMAT} /> <DateField source="last_access_ts" showTime options={DATE_FORMAT} />
<NumberField source="media_length" /> <NumberField source="media_length" />
<TextField source="media_type" /> <TextField source="media_type" sx={{ display: "block", width: 200, wordBreak: "break-word" }} />
<TextField source="upload_name" /> <FunctionField source="upload_name" render={record => decodeURIComponent(record.upload_name)} />
<TextField source="quarantined_by" /> <TextField source="quarantined_by" />
<QuarantineMediaButton label="resources.quarantine_media.action.name" /> <QuarantineMediaButton label="resources.quarantine_media.action.name" />
<ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" /> <ProtectMediaButton label="resources.users_media.fields.safe_from_quarantine" />

View File

@@ -96,8 +96,13 @@ const authProvider: AuthProvider = {
}; };
if (typeof access_token === "string") { if (typeof access_token === "string") {
await fetchUtils.fetchJson(logout_api_url, options); try {
storage.removeItem("access_token"); await fetchUtils.fetchJson(logout_api_url, options);
} catch (err) {
console.log("Error logging out", err);
} finally {
storage.removeItem("access_token");
}
} }
}, },
// called when the API returns an error // called when the API returns an error

View File

@@ -1,6 +1,7 @@
import { import {
DataProvider, DataProvider,
DeleteParams, DeleteParams,
DeleteManyParams,
HttpError, HttpError,
Identifier, Identifier,
Options, Options,
@@ -41,21 +42,16 @@ const jsonClient = async (url: string, options: Options = {}) => {
} }
}; };
const mxcUrlToHttp = (mxcUrl: string) => {
const homeserver = storage.getItem("base_url");
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) return null;
const serverName = ret[1];
const mediaId = ret[2];
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const filterUndefined = (obj: Record<string, any>) => { const filterUndefined = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined)); return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
}; };
interface Action {
endpoint: string;
method?: string;
body?: Record<string, any>;
}
interface Room { interface Room {
room_id: string; room_id: string;
name?: string; name?: string;
@@ -260,10 +256,10 @@ export interface SynapseDataProvider extends DataProvider {
const resourceMap = { const resourceMap = {
users: { users: {
path: "/_synapse/admin/v2/users", path: "/_synapse/admin/v2/users",
map: (u: User) => ({ map: async (u: User) => ({
...u, ...u,
id: returnMXID(u.name), id: returnMXID(u.name),
avatar_src: u.avatar_url ? mxcUrlToHttp(u.avatar_url) : undefined, avatar_src: u.avatar_url ? u.avatar_url : undefined,
is_guest: !!u.is_guest, is_guest: !!u.is_guest,
admin: !!u.admin, admin: !!u.admin,
deactivated: !!u.deactivated, deactivated: !!u.deactivated,
@@ -451,7 +447,7 @@ const resourceMap = {
id: rd.room_id, id: rd.room_id,
public: !!rd.public, public: !!rd.public,
guest_access: !!rd.guest_access, guest_access: !!rd.guest_access,
avatar_src: rd.avatar_url ? mxcUrlToHttp(rd.avatar_url) : undefined, avatar_src: rd.avatar_url ? rd.avatar_url : undefined,
}), }),
data: "chunk", data: "chunk",
total: json => json.total_room_count_estimate, total: json => json.total_room_count_estimate,
@@ -557,8 +553,10 @@ const baseDataProvider: SynapseDataProvider = {
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`; const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url); const { json } = await jsonClient(url);
const formattedData = await json[res.data].map(res.map);
return { return {
data: json[res.data].map(res.map), data: formattedData,
total: res.total(json, from, perPage), total: res.total(json, from, perPage),
}; };
}, },
@@ -714,7 +712,7 @@ const baseDataProvider: SynapseDataProvider = {
}, },
deleteMany: async (resource, params) => { deleteMany: async (resource, params) => {
console.log("deleteMany " + resource); console.log("deleteMany " + resource, "params", params);
const homeserver = storage.getItem("base_url"); const homeserver = storage.getItem("base_url");
if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set"); if (!homeserver || !(resource in resourceMap)) throw Error("Homeserver not set");
@@ -731,6 +729,7 @@ const baseDataProvider: SynapseDataProvider = {
}); });
}) })
); );
return { return {
data: responses.map(({ json }) => json), data: responses.map(({ json }) => json),
}; };
@@ -808,6 +807,39 @@ const dataProvider = withLifecycleCallbacks(baseDataProvider, [
} }
return params; return params;
}, },
beforeDelete: async (params: DeleteParams<any>, dataProvider: DataProvider) => {
if (params.meta?.deleteMedia) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(params.id))}/media`;
await jsonClient(endpoint_url, { method: "DELETE" });
}
if (params.meta?.redactEvents) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(params.id))}/redact`;
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
}
return params;
},
beforeDeleteMany: async (params: DeleteManyParams<any>, dataProvider: DataProvider) => {
await Promise.all(
params.ids.map(async id => {
if (params.meta?.deleteMedia) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/users/${encodeURIComponent(returnMXID(id))}/media`;
await jsonClient(endpoint_url, { method: "DELETE" });
}
if (params.meta?.redactEvents) {
const base_url = storage.getItem("base_url");
const endpoint_url = `${base_url}/_synapse/admin/v1/user/${encodeURIComponent(returnMXID(id))}/redact`;
await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify({ rooms: [] }) });
}
})
);
return params;
},
}, },
]); ]);

View File

@@ -54,11 +54,6 @@ export const getSupportedLoginFlows = async baseUrl => {
return response.json.flows; return response.json.flows;
}; };
export const getMediaUrl = media_id => {
const baseUrl = storage.getItem("base_url");
return `${baseUrl}/_matrix/media/v1/download/${media_id}?allow_redirect=true`;
};
/** /**
* Generate a random MXID for current homeserver * Generate a random MXID for current homeserver
* @returns full MXID as string * @returns full MXID as string

43
src/utils/fetchMedia.ts Normal file
View File

@@ -0,0 +1,43 @@
import storage from "../storage";
export const getServerAndMediaIdFromMxcUrl = (mxcUrl: string): { serverName: string, mediaId: string } => {
const re = /^mxc:\/\/([^/]+)\/(\w+)/;
const ret = re.exec(mxcUrl);
console.log("mxcClient " + ret);
if (ret == null) {
throw new Error("Invalid mxcUrl");
}
const serverName = ret[1];
const mediaId = ret[2];
return { serverName, mediaId };
};
export type MediaType = "thumbnail" | "original";
export const fetchAuthenticatedMedia = async (mxcUrl: string, type: MediaType): Promise<Response> => {
const homeserver = storage.getItem("base_url");
const accessToken = storage.getItem("access_token");
const { serverName, mediaId } = getServerAndMediaIdFromMxcUrl(mxcUrl);
if (!serverName || !mediaId) {
throw new Error("Invalid mxcUrl");
}
let url = "";
if (type === "thumbnail") {
// ref: https://spec.matrix.org/latest/client-server-api/#thumbnails
url = `${homeserver}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=320&height=240&method=scale`;
} else if (type === "original") {
url = `${homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
} else {
throw new Error("Invalid authenticated media type");
}
const response = await fetch(`${url}`, {
headers: {
authorization: `Bearer ${accessToken}`,
},
});
return response;
};