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
This commit is contained in:
Aine 2024-09-17 23:06:12 +03:00 committed by GitHub
parent d5113aad72
commit 24cf0a60bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 138 additions and 78 deletions

View file

@ -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

View file

@ -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) # 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)
[![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
This project is built using [react-admin](https://marmelab.com/react-admin/). This project is built using [react-admin](https://marmelab.com/react-admin/).
## Fork differences ## 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 ### Available via CDN
On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork. On [admin.etke.cc](https://admin.etke.cc) you can find the latest version of this fork.
### Changes ### 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: The following changes are already implemented:
* [Prevent admins from deleting themselves](https://github.com/etkecc/synapse-admin/pull/1) * [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) * [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 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) * [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_ _the list will be updated as new changes are added_

View file

@ -147,6 +147,7 @@ const de: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Lösche Benutzerdaten", erase: "Lösche Benutzerdaten",
erase_avatar: "Avatar löschen"
}, },
}, },
rooms: { rooms: {

View file

@ -146,6 +146,7 @@ const en: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Erase user data", erase: "Erase user data",
erase_avatar: "Erase avatar"
}, },
}, },
rooms: { rooms: {

View file

@ -144,6 +144,7 @@ const fr: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Effacer les données de l'utilisateur", erase: "Effacer les données de l'utilisateur",
erase_avatar: "Effacer l'avatar",
}, },
}, },
rooms: { rooms: {

1
src/i18n/index.d.ts vendored
View file

@ -142,6 +142,7 @@ interface SynapseTranslationMessages extends TranslationMessages {
}; };
action: { action: {
erase: string; erase: string;
erase_avatar: string;
}; };
}; };
rooms: { rooms: {

View file

@ -155,6 +155,7 @@ const ru: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "Удалить данные пользователя", erase: "Удалить данные пользователя",
erase_avatar: "Удалить аватар",
}, },
}, },
rooms: { rooms: {

View file

@ -139,6 +139,7 @@ const zh: SynapseTranslationMessages = {
}, },
action: { action: {
erase: "抹除用户信息", erase: "抹除用户信息",
erase_avatar: "抹掉头像",
}, },
}, },
rooms: { rooms: {

View file

@ -55,6 +55,8 @@ import {
ToolbarClasses, ToolbarClasses,
Identifier, Identifier,
RaRecord, RaRecord,
ImageInput,
ImageField,
} from "react-admin"; } from "react-admin";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -101,21 +103,19 @@ const userFilters = [
<BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />, <BooleanInput label="resources.users.fields.show_locked" source="locked" alwaysOn />,
]; ];
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 ownUserIsSelected = props.ownUserIsSelected;
const notify = useNotify(); const notify = useNotify();
const translate = useTranslate(); const translate = useTranslate();
const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => { const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
if (ownUserIsSelected) { if (ownUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>) notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
ev.stopPropagation(); ev.stopPropagation();
} }
}; };
return <div onClickCapture={handleDeleteClick}> return <div onClickCapture={handleDeleteClick}>{props.children}</div>;
{props.children}
</div>
}; };
const UserBulkActionButtons = () => { const UserBulkActionButtons = () => {
@ -130,8 +130,8 @@ const UserBulkActionButtons = () => {
setOwnUserIsSelected(selectedIds.includes(ownUserId)); setOwnUserIsSelected(selectedIds.includes(ownUserId));
}, [selectedIds]); }, [selectedIds]);
return (
return <> <>
<ServerNoticeBulkButton /> <ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}> <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BulkDeleteButton <BulkDeleteButton
@ -141,6 +141,7 @@ const UserBulkActionButtons = () => {
/> />
</UserPreventSelfDelete> </UserPreventSelfDelete>
</> </>
);
}; };
const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => { const usersRowClick = (id: Identifier, resource: string, record: RaRecord): string => {
@ -204,9 +205,12 @@ const UserEditActions = () => {
}; };
export const UserCreate = (props: CreateProps) => ( export const UserCreate = (props: CreateProps) => (
<Create { ...props} redirect={(resource, id, data) => { <Create
{...props}
redirect={(resource, id, data) => {
return `users/${id}`; return `users/${id}`;
}}> }}
>
<SimpleForm> <SimpleForm>
<TextInput source="id" autoComplete="off" validate={validateUser} /> <TextInput source="id" autoComplete="off" validate={validateUser} />
<TextInput source="displayname" validate={maxLength(256)} /> <TextInput source="displayname" validate={maxLength(256)} />
@ -250,7 +254,8 @@ const UserEditToolbar = () => {
ownUserIsSelected = record.id === ownUserId; ownUserIsSelected = record.id === ownUserId;
} }
return <> return (
<>
<div className={ToolbarClasses.defaultToolbar}> <div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}> <Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton /> <SaveButton />
@ -260,19 +265,23 @@ const UserEditToolbar = () => {
</Toolbar> </Toolbar>
</div> </div>
</> </>
);
}; };
const UserBooleanInput = (props) => { const UserBooleanInput = props => {
const record = useRecordContext(); const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id"); const ownUserId = localStorage.getItem("user_id");
const isOwnUser = false;
let ownUserIsSelected = false; let ownUserIsSelected = false;
if (record && (record.id === ownUserId)) { if (record && record.id === ownUserId) {
ownUserIsSelected = true; ownUserIsSelected = true;
} }
return <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}><BooleanInput {...props} disabled={ownUserIsSelected} /></UserPreventSelfDelete> return (
} <UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected} />
</UserPreventSelfDelete>
);
};
export const UserEdit = (props: EditProps) => { export const UserEdit = (props: EditProps) => {
const translate = useTranslate(); const translate = useTranslate();
@ -281,7 +290,11 @@ export const UserEdit = (props: EditProps) => {
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}> <Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<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", float: "right" }} /> <AvatarField source="avatar_src" sortable={false} sx={{ height: "120px", width: "120px" }} />
<BooleanInput source="avatar_erase" label="resources.users.action.erase_avatar" />
<ImageInput source="avatar_file" label="resources.users.fields.avatar" accept="image/*">
<ImageField source="src" title="Avatar" />
</ImageInput>
<TextInput source="id" disabled /> <TextInput source="id" disabled />
<TextInput source="displayname" /> <TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" /> <PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />

View file

@ -101,7 +101,7 @@ describe("authProvider", () => {
}); });
it("should reject if error.status is 401", async () => { 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 () => { it("should reject if error.status is 403", async () => {

View file

@ -18,7 +18,7 @@ describe("dataProvider", () => {
JSON.stringify({ JSON.stringify({
users: [ users: [
{ {
name: "user_id1", name: "@user_id1:provider",
password_hash: "password_hash1", password_hash: "password_hash1",
is_guest: 0, is_guest: 0,
admin: 0, admin: 0,
@ -27,7 +27,7 @@ describe("dataProvider", () => {
displayname: "User One", displayname: "User One",
}, },
{ {
name: "user_id2", name: "@user_id2:provider",
password_hash: "password_hash2", password_hash: "password_hash2",
is_guest: 0, is_guest: 0,
admin: 1, admin: 1,
@ -47,7 +47,7 @@ describe("dataProvider", () => {
filter: { author_id: 12 }, 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(users.total).toEqual(200);
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });
@ -55,7 +55,7 @@ describe("dataProvider", () => {
it("fetches one user", async () => { it("fetches one user", async () => {
fetchMock.mockResponseOnce( fetchMock.mockResponseOnce(
JSON.stringify({ JSON.stringify({
name: "user_id1", name: "@user_id1:provider",
password: "user_password", password: "user_password",
displayname: "User", displayname: "User",
threepids: [ 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(user.data.displayname).toEqual("User");
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });

View file

@ -1,16 +1,26 @@
import { stringify } from "query-string"; 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 storage from "../storage";
import { returnMXID } from "./synapse.ts" import { returnMXID } from "./synapse";
import { MatrixError, displayError } from "../components/error"; import { MatrixError, displayError } from "../components/error";
// Adds the access token to all requests // Adds the access token to all requests
const jsonClient = async (url: string, options: Options = {}) => { const jsonClient = async (url: string, options: Options = {}) => {
const token = storage.getItem("access_token"); const token = storage.getItem("access_token");
console.log("httpClient " + url); console.log("httpClient " + url);
if (token != null) { if (token !== null) {
options.user = { options.user = {
authenticated: true, authenticated: true,
token: `Bearer ${token}`, token: `Bearer ${token}`,
@ -23,7 +33,9 @@ const jsonClient = async (url: string, options: Options = {}) => {
const error = err as HttpError; const error = err as HttpError;
const errorStatus = error.status; const errorStatus = error.status;
const errorBody = error.body as MatrixError; 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)); return Promise.reject(new HttpError(errMsg, errorStatus, errorBody));
} }
@ -226,8 +238,19 @@ export interface DeleteMediaResult {
total: number; total: number;
} }
export interface UploadMediaParams {
file: File;
filename: string;
content_type: string;
}
export interface UploadMediaResult {
content_uri: string;
}
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>;
} }
const resourceMap = { const resourceMap = {
@ -500,7 +523,7 @@ function getSearchOrder(order: "ASC" | "DESC") {
} }
} }
const dataProvider: SynapseDataProvider = { const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
console.log("getList " + resource); console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter; 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" }); const { json } = await jsonClient(endpoint_url, { method: "POST" });
return json as DeleteMediaResult; 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<any>, 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; export default dataProvider;

View file

@ -1,4 +1,4 @@
import { fetchUtils } from "react-admin"; import { Identifier, fetchUtils } from "react-admin";
import storage from "../storage"; import storage from "../storage";
@ -77,17 +77,17 @@ export function generateRandomMxId(): string {
* @param input the input string * @param input the input string
* @returns full MXID as 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"); const homeserver = storage.getItem("home_server");
// Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":") // Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":")
const mxidPattern = /^@[^@:]+:[^@:]+$/; const mxidPattern = /^@[^@:]+:[^@:]+$/;
if (mxidPattern.test(input)) { if (typeof input === 'string' && mxidPattern.test(input)) {
return input; // Already a valid MXID return input; // Already a valid MXID
} }
// If input is not a valid MXID, assume it's a localpart and construct the 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}`; return `@${localpart}:${homeserver}`;
} }