From 24cf0a60bf128e125fa7b06447b3e32504345757 Mon Sep 17 00:00:00 2001
From: Aine <97398200+aine-etke@users.noreply.github.com>
Date: Tue, 17 Sep 2024 23:06:12 +0300
Subject: [PATCH] Expose user avatar URL field in the UI (#27)
* wip
* some fixes
* update readme
* update readme
* Add option to change/erase any user's avatar
* Fix README
* Remove mutationMode from Edit
* remove log
* update readme
---
.github/CONTRIBUTING.md | 16 -------
README.md | 18 +++----
src/i18n/de.ts | 1 +
src/i18n/en.ts | 1 +
src/i18n/fr.ts | 1 +
src/i18n/index.d.ts | 1 +
src/i18n/ru.ts | 1 +
src/i18n/zh.ts | 1 +
src/resources/users.tsx | 81 ++++++++++++++++++--------------
src/synapse/authProvider.test.ts | 2 +-
src/synapse/dataProvider.test.ts | 12 ++---
src/synapse/dataProvider.ts | 73 ++++++++++++++++++++++++++--
src/synapse/synapse.ts | 8 ++--
13 files changed, 138 insertions(+), 78 deletions(-)
delete mode 100644 .github/CONTRIBUTING.md
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
deleted file mode 100644
index 5bcd606..0000000
--- a/.github/CONTRIBUTING.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Contributing to [etkecc/synapse-admin](https://github.com/etkecc/synapse-admin)
-
-While etke.cc fork is intended to accept more QoL changes and features,
-it's good idea to open PR into the upstream repo: [Awesome-Technologies/Synapse-Admin](https://github.com/Awesome-Technologies/synapse-admin).
-
-1. Use the etkecc/synapse-admin **master** branch as your branch upstream: `git checkout master; git pull; git checkout -b my-new-feature`
-2. Once your changes are ready, please, open **2** PRs: one from your branch to `Awesome-Technologies/Synapse-Admin` **master**, and another one to `etkecc/synapse-admin` **main**
-3. Once PR is accepted in the `etkecc/synapse-admin`, update `README.md` file (either directly in the `main` branch, or via another PR) to add link to the merged PR in the [Fork differences](https://github.com/etkecc/synapse-admin#fork-differences) section
-
-### Why?
-
-The upstream project may not want to accept all the changes, so to ensure they are not lost, we will gladly add them to the etke.cc fork.
-Unfortunately, it's challenging to keep changes separated, so to avoid messing upstream and fork changes (e.g., CI changes that should not be pushed to the upstream, as they intended for this fork specifically), there are 2 branches:
-
-* `master` - read-only copy of upstream's master branch to easily sync changes, and use it as base for new PRs
-* `main` - fork-own branch with all changes
diff --git a/README.md b/README.md
index 46c98d7..a0c1be2 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,19 @@
-[![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
-[![Build Status](https://api.travis-ci.com/Awesome-Technologies/synapse-admin.svg?branch=master)](https://app.travis-ci.com/github/Awesome-Technologies/synapse-admin)
-[![build-test](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/build-test.yml)
-[![gh-pages](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/edge_ghpage.yml/badge.svg)](https://awesome-technologies.github.io/synapse-admin/)
-[![docker-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/docker-release.yml/badge.svg)](https://hub.docker.com/r/awesometechnologies/synapse-admin)
-[![github-release](https://github.com/Awesome-Technologies/synapse-admin/actions/workflows/github-release.yml/badge.svg)](https://github.com/Awesome-Technologies/synapse-admin/releases)
-
-# Synapse admin ui
+# Synapse Admin UI [![GitHub license](https://img.shields.io/github/license/Awesome-Technologies/synapse-admin)](https://github.com/Awesome-Technologies/synapse-admin/blob/master/LICENSE)
This project is built using [react-admin](https://marmelab.com/react-admin/).
## Fork differences
+With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
+fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
+user-friendly interface for managing Synapse homeservers.
+
### Available via CDN
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
### Changes
-With [Awesome-Technologies/synapse-admin](https://github.com/Awesome-Technologies/synapse-admin) as the upstream, this
-fork is intended to be a more feature-rich version of the original project. The main goal is to provide a more
-user-friendly interface for managing Synapse homeservers.
-
The following changes are already implemented:
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1)
@@ -38,6 +31,7 @@ The following changes are already implemented:
* [Add UI option to block deleted rooms from being rejoined](https://github.com/etkecc/synapse-admin/pull/26)
* [Fix required fields check on Bulk registration CSV upload](https://github.com/etkecc/synapse-admin/pull/32)
* [Fix requests with invalid MXIDs on Bulk registration](https://github.com/etkecc/synapse-admin/pull/33)
+* [Expose user avatar URL field in the UI](https://github.com/etkecc/synapse-admin/pull/27)
_the list will be updated as new changes are added_
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index d5034f8..a6d9c73 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -147,6 +147,7 @@ const de: SynapseTranslationMessages = {
},
action: {
erase: "Lösche Benutzerdaten",
+ erase_avatar: "Avatar löschen"
},
},
rooms: {
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index a44d405..c3de63c 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -146,6 +146,7 @@ const en: SynapseTranslationMessages = {
},
action: {
erase: "Erase user data",
+ erase_avatar: "Erase avatar"
},
},
rooms: {
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 50ac6d0..0506243 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -144,6 +144,7 @@ const fr: SynapseTranslationMessages = {
},
action: {
erase: "Effacer les données de l'utilisateur",
+ erase_avatar: "Effacer l'avatar",
},
},
rooms: {
diff --git a/src/i18n/index.d.ts b/src/i18n/index.d.ts
index 325fe21..abef700 100644
--- a/src/i18n/index.d.ts
+++ b/src/i18n/index.d.ts
@@ -142,6 +142,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
};
action: {
erase: string;
+ erase_avatar: string;
};
};
rooms: {
diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts
index d5380ec..cc26253 100644
--- a/src/i18n/ru.ts
+++ b/src/i18n/ru.ts
@@ -155,6 +155,7 @@ const ru: SynapseTranslationMessages = {
},
action: {
erase: "Удалить данные пользователя",
+ erase_avatar: "Удалить аватар",
},
},
rooms: {
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 61a59cb..5936830 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -139,6 +139,7 @@ const zh: SynapseTranslationMessages = {
},
action: {
erase: "抹除用户信息",
+ erase_avatar: "抹掉头像",
},
},
rooms: {
diff --git a/src/resources/users.tsx b/src/resources/users.tsx
index 68eb04b..b1086d8 100644
--- a/src/resources/users.tsx
+++ b/src/resources/users.tsx
@@ -55,6 +55,8 @@ import {
ToolbarClasses,
Identifier,
RaRecord,
+ ImageInput,
+ ImageField,
} from "react-admin";
import { Link } from "react-router-dom";
@@ -101,26 +103,24 @@ const userFilters = [
,
];
-const UserPreventSelfDelete: React.FC<{ children: React.ReactNode, ownUserIsSelected: boolean }> = (props) => {
+const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSelected: boolean }> = props => {
const ownUserIsSelected = props.ownUserIsSelected;
const notify = useNotify();
const translate = useTranslate();
const handleDeleteClick = (ev: React.MouseEvent) => {
if (ownUserIsSelected) {
- notify({translate("resources.users.helper.erase_admin_error")})
+ notify({translate("resources.users.helper.erase_admin_error")});
ev.stopPropagation();
}
};
- return
- {props.children}
-
+ return {props.children}
;
};
const UserBulkActionButtons = () => {
const record = useListContext();
- const [ ownUserIsSelected, setOwnUserIsSelected ] = useState(false);
+ const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id");
const notify = useNotify();
@@ -128,19 +128,20 @@ const UserBulkActionButtons = () => {
useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId));
- }, [ selectedIds ]);
+ }, [selectedIds]);
-
- return <>
-
-
-
-
- >
+ return (
+ <>
+
+
+
+
+ >
+ );
};
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
@@ -204,9 +205,12 @@ const UserEditActions = () => {
};
export const UserCreate = (props: CreateProps) => (
- {
- return `users/${id}`;
- }}>
+ {
+ return `users/${id}`;
+ }}
+ >
@@ -237,7 +241,7 @@ const UserTitle = () => {
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
- {record ? ( record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
+ {record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
);
};
@@ -250,29 +254,34 @@ const UserEditToolbar = () => {
ownUserIsSelected = record.id === ownUserId;
}
- return <>
-
-
+ return (
+ <>
+
+
-
-
- >
+
+
+ >
+ );
};
-const UserBooleanInput = (props) => {
+const UserBooleanInput = props => {
const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id");
- const isOwnUser = false;
let ownUserIsSelected = false;
- if (record && (record.id === ownUserId)) {
+ if (record && record.id === ownUserId) {
ownUserIsSelected = true;
}
- return
-}
+ return (
+
+
+
+ );
+};
export const UserEdit = (props: EditProps) => {
const translate = useTranslate();
@@ -281,7 +290,11 @@ export const UserEdit = (props: EditProps) => {
} actions={}>
}>
}>
-
+
+
+
+
+
diff --git a/src/synapse/authProvider.test.ts b/src/synapse/authProvider.test.ts
index 340f5c3..3dddc74 100644
--- a/src/synapse/authProvider.test.ts
+++ b/src/synapse/authProvider.test.ts
@@ -101,7 +101,7 @@ describe("authProvider", () => {
});
it("should reject if error.status is 401", async () => {
- await expect(authProvider.checkError({ status: 401 })).rejects.toBeUndefined();
+ await expect(authProvider.checkError(new HttpError("test-error", 401, {errcode: "test-errcode", error: "test-error"}))).rejects.toBeDefined();
});
it("should reject if error.status is 403", async () => {
diff --git a/src/synapse/dataProvider.test.ts b/src/synapse/dataProvider.test.ts
index a578674..dbf9b45 100644
--- a/src/synapse/dataProvider.test.ts
+++ b/src/synapse/dataProvider.test.ts
@@ -18,7 +18,7 @@ describe("dataProvider", () => {
JSON.stringify({
users: [
{
- name: "user_id1",
+ name: "@user_id1:provider",
password_hash: "password_hash1",
is_guest: 0,
admin: 0,
@@ -27,7 +27,7 @@ describe("dataProvider", () => {
displayname: "User One",
},
{
- name: "user_id2",
+ name: "@user_id2:provider",
password_hash: "password_hash2",
is_guest: 0,
admin: 1,
@@ -47,7 +47,7 @@ describe("dataProvider", () => {
filter: { author_id: 12 },
});
- expect(users.data[0].id).toEqual("user_id1");
+ expect(users.data[0].id).toEqual("@user_id1:provider");
expect(users.total).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1);
});
@@ -55,7 +55,7 @@ describe("dataProvider", () => {
it("fetches one user", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({
- name: "user_id1",
+ name: "@user_id1:provider",
password: "user_password",
displayname: "User",
threepids: [
@@ -74,9 +74,9 @@ describe("dataProvider", () => {
})
);
- const user = await dataProvider.getOne("users", { id: "user_id1" });
+ const user = await dataProvider.getOne("users", { id: "@user_id1:provider" });
- expect(user.data.id).toEqual("user_id1");
+ expect(user.data.id).toEqual("@user_id1:provider");
expect(user.data.displayname).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1);
});
diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts
index ada0b91..fda55d1 100644
--- a/src/synapse/dataProvider.ts
+++ b/src/synapse/dataProvider.ts
@@ -1,16 +1,26 @@
import { stringify } from "query-string";
-import { DataProvider, DeleteParams, HttpError, Identifier, Options, RaRecord, fetchUtils } from "react-admin";
+import {
+ DataProvider,
+ DeleteParams,
+ HttpError,
+ Identifier,
+ Options,
+ RaRecord,
+ UpdateParams,
+ fetchUtils,
+ withLifecycleCallbacks,
+} from "react-admin";
import storage from "../storage";
-import { returnMXID } from "./synapse.ts"
+import { returnMXID } from "./synapse";
import { MatrixError, displayError } from "../components/error";
// Adds the access token to all requests
const jsonClient = async (url: string, options: Options = {}) => {
const token = storage.getItem("access_token");
console.log("httpClient " + url);
- if (token != null) {
+ if (token !== null) {
options.user = {
authenticated: true,
token: `Bearer ${token}`,
@@ -23,7 +33,9 @@ const jsonClient = async (url: string, options: Options = {}) => {
const error = err as HttpError;
const errorStatus = error.status;
const errorBody = error.body as MatrixError;
- const errMsg = !!errorBody?.errcode ? displayError(errorBody.errcode, errorStatus, errorBody.error) : displayError("M_INVALID", errorStatus, error.message);
+ const errMsg = !!errorBody?.errcode
+ ? displayError(errorBody.errcode, errorStatus, errorBody.error)
+ : displayError("M_INVALID", errorStatus, error.message);
return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
}
@@ -226,8 +238,19 @@ export interface DeleteMediaResult {
total: number;
}
+export interface UploadMediaParams {
+ file: File;
+ filename: string;
+ content_type: string;
+}
+
+export interface UploadMediaResult {
+ content_uri: string;
+}
+
export interface SynapseDataProvider extends DataProvider {
deleteMedia: (params: DeleteMediaParams) => Promise;
+ uploadMedia: (params: UploadMediaParams) => Promise;
}
const resourceMap = {
@@ -500,7 +523,7 @@ function getSearchOrder(order: "ASC" | "DESC") {
}
}
-const dataProvider: SynapseDataProvider = {
+const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
@@ -742,6 +765,46 @@ const dataProvider: SynapseDataProvider = {
const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult;
},
+
+ uploadMedia: async ({ file, filename, content_type }: UploadMediaParams) => {
+ const base_url = storage.getItem("base_url");
+ const uploadMediaURL = `${base_url}/_matrix/media/v3/upload`;
+
+ const { json } = await jsonClient(`${uploadMediaURL}?filename=${filename}`, {
+ method: "POST",
+ body: file,
+ headers: new Headers({
+ Accept: "application/json",
+ "Content-Type": content_type,
+ }) as Headers,
+ });
+ return json as UploadMediaResult;
+ },
};
+const dataProvider = withLifecycleCallbacks(baseDataProvider, [
+ {
+ resource: "users",
+ beforeUpdate: async (params: UpdateParams, dataProvider: DataProvider) => {
+ const avatarFile = params.data.avatar_file?.rawFile;
+ const avatarErase = params.data.avatar_erase;
+
+ if (avatarErase) {
+ params.data.avatar_url = "";
+ return params;
+ }
+
+ if (avatarFile instanceof File) {
+ const reponse = await dataProvider.uploadMedia({
+ file: avatarFile,
+ filename: params.data.avatar_file.title,
+ content_type: params.data.avatar_file.rawFile.type,
+ });
+ params.data.avatar_url = reponse.content_uri;
+ }
+ return params;
+ },
+ },
+]);
+
export default dataProvider;
diff --git a/src/synapse/synapse.ts b/src/synapse/synapse.ts
index 13c18f3..2e49501 100644
--- a/src/synapse/synapse.ts
+++ b/src/synapse/synapse.ts
@@ -1,4 +1,4 @@
-import { fetchUtils } from "react-admin";
+import { Identifier, fetchUtils } from "react-admin";
import storage from "../storage";
@@ -77,17 +77,17 @@ export function generateRandomMxId(): string {
* @param input the input string
* @returns full MXID as string
*/
-export function returnMXID(input: string): string {
+export function returnMXID(input: string | Identifier): string {
const homeserver = storage.getItem("home_server");
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
const mxidPattern = /^@[^@:]+:[^@:]+$/;
- if (mxidPattern.test(input)) {
+ if (typeof input === 'string' && mxidPattern.test(input)) {
return input; // Already a valid MXID
}
// If input is not a valid MXID, assume it's a localpart and construct the MXID
- const localpart = input.startsWith('@') ? input.slice(1) : input;
+ const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input;
return `@${localpart}:${homeserver}`;
}