Compare commits

4 Commits

Author SHA1 Message Date
Aine
52a2f1c936 reword managed user error 2024-09-24 21:58:24 +03:00
Aine
e328380c77 Restrict actions on specific users (#42)
* Restrict actions on specific users

* update readme
2024-09-24 13:25:29 +03:00
dependabot[bot]
a277ded227 Bump rollup from 4.22.0 to 4.22.4 (#41)
Bumps [rollup](https://github.com/rollup/rollup) from 4.22.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.22.0...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 10:18:09 +03:00
Borislav Pantaleev
48d933e028 Upgrade react-admin to version 5 (#40)
* wip on ra upgrade

* Finish upgrade

* fix package.json

* pr fixes

* more pr fixes

* update readme
2024-09-24 10:02:47 +03:00
28 changed files with 6054 additions and 8604 deletions

View File

@@ -5,6 +5,6 @@
},
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@@ -1 +0,0 @@
yarnPath: .yarn/releases/yarn-4.1.1.cjs

View File

@@ -32,6 +32,8 @@ The following changes are already implemented:
* [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)
* [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)
_the list will be updated as new changes are added_
@@ -125,37 +127,6 @@ You have three options:
- browse to http://localhost:8080
### Restricting available homeserver
You can restrict the homeserver(s), so that the user can no longer define it himself.
Edit `config.json` to restrict either to a single homeserver:
```json
{
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
}
```
or to a list of homeservers:
```json
{
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
}
```
The `config.json` can be injected into a Docker container using a bind mount.
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
```
### Serving Synapse-Admin on a different path
The path prefix where synapse-admin is served can only be changed during the build step.
@@ -193,6 +164,54 @@ services:
- "traefik.http.middlewares.admin_path.stripprefix.prefixes=/admin"
```
## Configuration
You can use `config.json` file to configure synapse-admin
The `config.json` can be injected into a Docker container using a bind mount.
```yml
services:
synapse-admin:
...
volumes:
./config.json:/app/config.json:ro
...
```
### Restricting available homeserver
You can restrict the homeserver(s), so that the user can no longer define it himself.
Edit `config.json` to restrict either to a single homeserver:
```json
{
"restrictBaseUrl": "https://your-matrixs-erver.example.com"
}
```
or to a list of homeservers:
```json
{
"restrictBaseUrl": ["https://your-first-matrix-server.example.com", "https://your-second-matrix-server.example.com"]
}
```
### Protecting appservice managed users
To avoid accidental adjustments of appservice-managed users (e.g., puppets created by a bridge) and breaking the bridge,
you can specify the list of MXIDs (regexp) that should be prohibited from any changes, except display name and avatar.
Example for [mautrix-telegram](https://github.com/mautrix/telegram)
```json
{
"asManagedUsers": ["^@telegram_[a-zA-Z0-9]+:example\\.com$"]
}
```
## Screenshots
![Screenshots](./screenshots.jpg)

View File

@@ -10,21 +10,20 @@
"type": "git",
"url": "https://github.com/etkecc/synapse-admin"
},
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@eslint/js": "^9.7.0",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.12",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
@@ -37,11 +36,11 @@
"jest-fetch-mock": "^3.0.3",
"prettier": "^3.3.3",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.2.3",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.16.1",
"vite": "^5.3.4",
"vite": "^5.4.6",
"vite-plugin-version-mark": "^0.1.0"
},
"dependencies": {
@@ -49,27 +48,26 @@
"@emotion/styled": "^11.13.0",
"@haleos/ra-language-german": "^1.0.0",
"@haxqer/ra-language-chinese": "^4.16.2",
"@mui/icons-material": "^5.16.4",
"@mui/material": "^5.16.4",
"@mui/icons-material": "^6.1.1",
"@mui/material": "^6.1.1",
"@tanstack/react-query": "^5.56.2",
"history": "^5.3.0",
"lodash": "^4.17.21",
"papaparse": "^5.4.1",
"query-string": "^7.1.3",
"ra-core": "^4.16.20",
"ra-i18n-polyglot": "^4.16.20",
"ra-language-english": "^4.16.20",
"ra-language-farsi": "^4.2.0",
"ra-language-french": "^4.16.20",
"ra-core": "^5.2.0",
"ra-i18n-polyglot": "^5.2.0",
"ra-language-english": "^5.2.0",
"ra-language-farsi": "^5.0.0",
"ra-language-french": "^5.2.0",
"ra-language-italian": "^3.13.1",
"ra-language-russian": "^4.14.2",
"react": "^18.3.1",
"react-admin": "^4.16.20",
"react-admin": "^5.2.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.1",
"react-hook-form": "^7.53.0",
"react-is": "^18.3.1",
"react-query": "^3.39.3",
"react-router": "^6.25.1",
"react-router-dom": "^6.25.1"
"react-router": "^6.26.2",
"react-router-dom": "^6.26.2"
},
"scripts": {
"start": "vite serve",

View File

@@ -1,4 +1,6 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
import App from "./App";
@@ -7,4 +9,4 @@ describe("App", () => {
render(<App />);
await screen.findAllByText("Welcome to Synapse-admin");
});
});
});

View File

@@ -53,7 +53,6 @@ const App = () => (
authProvider={authProvider}
dataProvider={dataProvider}
i18nProvider={i18nProvider}
darkTheme={{ palette: { mode: "dark" } }}
>
<CustomRoutes>
<Route path="/import_users" element={<ImportFeature />} />

View File

@@ -2,6 +2,7 @@ import { createContext, useContext } from "react";
interface AppContextType {
restrictBaseUrl: string | string[];
asManagedUsers: string[];
}
export const AppContext = createContext({});

View File

@@ -74,7 +74,6 @@ const DeleteRoomButton: React.FC<DeleteRoomButtonProps> = (props) => {
<DialogContentText>{translate(props.confirmContent)}</DialogContentText>
<SimpleForm toolbar={false}>
<BooleanInput
fullWidth
source="block"
value={block}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setBlock(event.target.checked)}

View File

@@ -20,7 +20,7 @@ import {
useTranslate,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
const translate = useTranslate();
@@ -43,7 +43,6 @@ const ServerNoticeDialog = ({ open, onClose, onSubmit }) => {
<TextInput
source="body"
label="resources.servernotices.fields.body"
fullWidth
multiline
rows="4"
resettable
@@ -64,6 +63,10 @@ export const ServerNoticeButton = () => {
const handleDialogOpen = () => setOpen(true);
const handleDialogClose = () => setOpen(false);
if (!record) {
return null;
}
const handleSend = (values: Partial<RaRecord>) => {
create(
"servernotices",
@@ -100,28 +103,26 @@ export const ServerNoticeBulkButton = () => {
const unselectAllUsers = useUnselectAll("users");
const dataProvider = useDataProvider();
const { mutate: sendNotices, isLoading } = useMutation(
data =>
const { mutate: sendNotices, isPending } = useMutation({
mutationFn: (data) =>
dataProvider.createMany("servernotices", {
ids: selectedIds,
data: data,
}),
{
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
}
);
onSuccess: () => {
notify("resources.servernotices.action.send_success");
unselectAllUsers();
closeDialog();
},
onError: () =>
notify("resources.servernotices.action.send_failure", {
type: "error",
}),
});
return (
<>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isLoading}>
<Button label="resources.servernotices.send" onClick={openDialog} disabled={isPending}>
<MessageIcon />
</Button>
<ServerNoticeDialog open={open} onClose={closeDialog} onSubmit={sendNotices} />

View File

@@ -1,9 +1,15 @@
import { DeleteWithConfirmButton, DeleteWithConfirmButtonProps, useRecordContext } from "react-admin";
import { isASManaged } from "./mxid";
export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
const record = useRecordContext();
if (!record) return null;
let isASManagedUser = false;
if (record.user_id) {
isASManagedUser = isASManaged(record.user_id);
}
return (
<DeleteWithConfirmButton
{...props}
@@ -12,6 +18,7 @@ export const DeviceRemoveButton = (props: DeleteWithConfirmButtonProps) => {
confirmContent="resources.devices.action.erase.content"
mutationMode="pessimistic"
redirect={false}
disabled={isASManagedUser}
translateOptions={{
id: record.id,
name: record.display_name ? record.display_name : record.id,

View File

@@ -28,7 +28,7 @@ import {
useRefresh,
useTranslate,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { dateParser } from "./date";
@@ -55,14 +55,12 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
<DialogContentText>{translate("delete_media.helper.send")}</DialogContentText>
<SimpleForm toolbar={<DeleteMediaToolbar />} onSubmit={onSubmit}>
<DateTimeInput
fullWidth
source="before_ts"
label="delete_media.fields.before_ts"
defaultValue={0}
parse={dateParser}
/>
<NumberInput
fullWidth
source="size_gt"
label="delete_media.fields.size_gt"
defaultValue={0}
@@ -70,7 +68,6 @@ const DeleteMediaDialog = ({ open, onClose, onSubmit }) => {
step={1024}
/>
<BooleanInput
fullWidth
source="keep_profiles"
label="delete_media.fields.keep_profiles"
defaultValue={true}
@@ -86,20 +83,18 @@ export const DeleteMediaButton = (props: ButtonProps) => {
const [open, setOpen] = useState(false);
const notify = useNotify();
const dataProvider = useDataProvider<SynapseDataProvider>();
const { mutate: deleteMedia, isLoading } = useMutation(
(values: DeleteMediaParams) => dataProvider.deleteMedia(values),
{
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
}
);
const { mutate: deleteMedia, isPending } = useMutation({
mutationFn: (values: DeleteMediaParams) => dataProvider.deleteMedia(values),
onSuccess: () => {
notify("delete_media.action.send_success");
closeDialog();
},
onError: () => {
notify("delete_media.action.send_failure", {
type: "error",
});
},
});
const openDialog = () => setOpen(true);
const closeDialog = () => setOpen(false);
@@ -110,7 +105,7 @@ export const DeleteMediaButton = (props: ButtonProps) => {
{...props}
label="delete_media.action.send"
onClick={openDialog}
disabled={isLoading}
disabled={isPending}
sx={{
color: theme.palette.error.main,
"&:hover": {

15
src/components/mxid.tsx Normal file
View File

@@ -0,0 +1,15 @@
/**
* Check if a user is managed by an application service
* @param id The user ID to check
* @returns Whether the user is managed by an application service
*/
export const isASManaged = (id: string) => {
const managedUsersString = localStorage.getItem("as_managed_users");
try {
const asManagedUsers = JSON.parse(managedUsersString).map(regex => new RegExp(regex));
return asManagedUsers.some(regex => regex.test(id));
} catch (e) {
return false;
}
};

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const de: SynapseTranslationMessages = {
...formalGermanMessages,
ra: {
...formalGermanMessages.ra,
navigation: {
...formalGermanMessages.ra.navigation,
no_filtered_results: "Keine Ergebnisse",
clear_filters: "Alle Filter entfernen",
},
},
synapseadmin: {
auth: {
base_url: "Heimserver URL",
@@ -142,8 +150,9 @@ const de: SynapseTranslationMessages = {
helper: {
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.",
erase: "DSGVO konformes Löschen der Benutzerdaten",
erase_admin_error: "Das Löschen des eigenen Benutzers ist nicht erlaubt",
erase: "DSGVO konformes Löschen der Benutzerdaten.",
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.",
},
action: {
erase: "Lösche Benutzerdaten",

View File

@@ -143,6 +143,7 @@ const en: SynapseTranslationMessages = {
deactivate: "You must provide a password to re-activate an account.",
erase: "Mark the user as GDPR-erased",
erase_admin_error: "Deleting own user is not allowed.",
modify_managed_user_error: "Modifying a system-managed user is not allowed.",
},
action: {
erase: "Erase user data",

View File

@@ -138,6 +138,8 @@ const fa: SynapseTranslationMessages = {
password: "با تغییر رمز عبور کاربر از تمام دستگاه ها خارج می شود.",
deactivate: "برای فعالسازی مجدد حساب باید رمز عبور وارد کنید.",
erase: "کاربر را به عنوان GDPR پاک شده علامت گذاری کنید",
erase_admin_error: "حذف المستخدم الخاص غير مسموح به.",
modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.",
},
action: {
erase: "پاک کردن اطلاعات کاربر",

View File

@@ -141,6 +141,7 @@ const fr: SynapseTranslationMessages = {
deactivate: "Vous devrez fournir un mot de passe pour réactiver le compte.",
erase: "Marquer l'utilisateur comme effacé conformément au RGPD",
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.",
},
action: {
erase: "Effacer les données de l'utilisateur",

View File

@@ -143,6 +143,7 @@ const it: SynapseTranslationMessages = {
action: {
erase: "Cancella i dati dell'utente",
erase_admin_error: "Non è consentito eliminare il proprio utente.",
modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.",
},
},
rooms: {

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const ru: SynapseTranslationMessages = {
...russianMessages,
ra: {
...russianMessages.ra,
navigation: {
...russianMessages.ra.navigation,
no_filtered_results: "Нет результатов",
clear_filters: "Все фильтры сбросить",
},
},
synapseadmin: {
auth: {
base_url: "Адрес домашнего сервера",
@@ -152,6 +160,7 @@ const ru: SynapseTranslationMessages = {
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_admin_error: "Удаление собственного пользователя запрещено.",
modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.",
},
action: {
erase: "Удалить данные пользователя",

View File

@@ -4,6 +4,14 @@ import { SynapseTranslationMessages } from ".";
const zh: SynapseTranslationMessages = {
...chineseMessages,
ra: {
...chineseMessages.ra,
navigation: {
...chineseMessages.ra.navigation,
no_filtered_results: "没有结果",
clear_filters: "清除所有过滤器",
},
},
synapseadmin: {
auth: {
base_url: "服务器 URL",
@@ -136,6 +144,7 @@ const zh: SynapseTranslationMessages = {
deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_admin_error: "不允许删除自己的用户",
modify_managed_user_error: "不允许修改系统管理的用户。",
},
action: {
erase: "抹除用户信息",

View File

@@ -4,15 +4,17 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import { AppContext } from "./AppContext";
import storage from "./storage";
fetch("config.json")
.then(res => res.json())
.then(props =>
createRoot(document.getElementById("root")).render(
.then(props => {
storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers));
return createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AppContext.Provider value={props}>
<App />
</AppContext.Provider>
</React.StrictMode>
)
);
});

View File

@@ -8,14 +8,17 @@ import { AppContext } from "../AppContext";
import englishMessages from "../i18n/en";
const i18nProvider = polyglotI18nProvider(() => englishMessages, "en", [{ locale: "en", name: "English" }]);
import { act } from "@testing-library/react";
describe("LoginForm", () => {
it("renders with no restriction to homeserver", () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
it("renders with no restriction to homeserver", async () => {
await act(async () => {
render(
<AdminContext i18nProvider={i18nProvider}>
<LoginPage />
</AdminContext>
);
});
screen.getByText(englishMessages.synapseadmin.auth.welcome);
screen.getByRole("combobox", { name: "" });

View File

@@ -222,7 +222,6 @@ const LoginPage = () => {
disabled={loading || !supportPassAuth}
onBlur={handleUsernameChange}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -234,7 +233,6 @@ const LoginPage = () => {
autoComplete="current-password"
disabled={loading || !supportPassAuth}
resettable
fullWidth
validate={required()}
/>
</Box>
@@ -247,7 +245,6 @@ const LoginPage = () => {
disabled={loading}
readOnly={allowSingleBaseUrl}
resettable={allowAnyBaseUrl}
fullWidth
validate={[required(), validateBaseUrl]}
>
{allowMultipleBaseUrls &&
@@ -280,9 +277,9 @@ const LoginPage = () => {
<Box className="hint">{translate("synapseadmin.auth.welcome")}</Box>
<Box className="form">
<Select
fullWidth
value={locale}
onChange={e => setLocale(e.target.value)}
fullWidth
disabled={loading}
className="select"
>

View File

@@ -25,7 +25,7 @@ import {
useRefresh,
useUnselectAll,
} from "react-admin";
import { useMutation } from "react-query";
import { useMutation } from "@tanstack/react-query";
import AvatarField from "../components/AvatarField";
@@ -70,27 +70,25 @@ export const RoomDirectoryBulkPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const unselectAllRooms = useUnselectAll("rooms");
const dataProvider = useDataProvider();
const { mutate, isLoading } = useMutation(
() =>
const { mutate, isPending } = useMutation({
mutationFn: () =>
dataProvider.createMany("room_directory", {
ids: selectedIds,
data: {},
}),
{
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
}
);
onSuccess: () => {
notify("resources.room_directory.action.send_success");
unselectAllRooms();
refresh();
},
onError: () =>
notify("resources.room_directory.action.send_failure", {
type: "error",
}),
});
return (
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isLoading}>
<Button {...props} label="resources.room_directory.action.create" onClick={mutate} disabled={isPending}>
<RoomDirectoryIcon />
</Button>
);
@@ -102,6 +100,10 @@ export const RoomDirectoryPublishButton = (props: ButtonProps) => {
const refresh = useRefresh();
const [create, { isLoading }] = useCreate();
if (!record) {
return null;
}
const handleSend = () => {
create(
"room_directory",

View File

@@ -67,6 +67,9 @@ const RoomTitle = () => {
const RoomShowActions = () => {
const record = useRecordContext();
if (!record) {
return null;
}
const publishButton = record?.public ? <RoomDirectoryUnpublishButton /> : <RoomDirectoryPublishButton />;
// FIXME: refresh after (un)publish
return (

View File

@@ -61,6 +61,7 @@ import {
import { Link } from "react-router-dom";
import AvatarField from "../components/AvatarField";
import { isASManaged } from "../components/mxid";
import { ServerNoticeButton, ServerNoticeBulkButton } from "../components/ServerNotices";
import { DATE_FORMAT } from "../components/date";
import { DeviceRemoveButton } from "../components/devices";
@@ -103,8 +104,9 @@ const userFilters = [
<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; asManagedUserIsSelected: boolean }> = props => {
const ownUserIsSelected = props.ownUserIsSelected;
const asManagedUserIsSelected = props.asManagedUserIsSelected;
const notify = useNotify();
const translate = useTranslate();
@@ -112,6 +114,9 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
if (ownUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.erase_admin_error")}</Alert>);
ev.stopPropagation();
} else if (asManagedUserIsSelected) {
notify(<Alert severity="error">{translate("resources.users.helper.modify_managed_user_error")}</Alert>);
ev.stopPropagation();
}
};
@@ -121,6 +126,7 @@ const UserPreventSelfDelete: React.FC<{ children: React.ReactNode; ownUserIsSele
const UserBulkActionButtons = () => {
const record = useListContext();
const [ownUserIsSelected, setOwnUserIsSelected] = useState(false);
const [asManagedUserIsSelected, setAsManagedUserIsSelected] = useState(false);
const selectedIds = record.selectedIds;
const ownUserId = localStorage.getItem("user_id");
const notify = useNotify();
@@ -128,12 +134,13 @@ const UserBulkActionButtons = () => {
useEffect(() => {
setOwnUserIsSelected(selectedIds.includes(ownUserId));
setAsManagedUserIsSelected(selectedIds.some(id => isASManaged(id)));
}, [selectedIds]);
return (
<>
<ServerNoticeBulkButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BulkDeleteButton
label="resources.users.action.erase"
confirmTitle="resources.users.helper.erase"
@@ -184,14 +191,16 @@ const UserEditActions = () => {
const translate = useTranslate();
const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<TopToolbar>
{!record?.deactivated && <ServerNoticeButton />}
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton
label="resources.users.action.erase"
confirmTitle={translate("resources.users.helper.erase", {
@@ -236,12 +245,16 @@ export const UserCreate = (props: CreateProps) => (
const UserTitle = () => {
const record = useRecordContext();
const translate = useTranslate();
let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""
if (isASManaged(record?.id)) {
username += " 🤖";
}
return (
<span>
{translate("resources.users.name", {
smart_count: 1,
})}{" "}
{record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""}
{username}
</span>
);
};
@@ -250,8 +263,10 @@ const UserEditToolbar = () => {
const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false;
let asManagedUserIsSelected = false;
if (record && record.id) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return (
@@ -259,7 +274,7 @@ const UserEditToolbar = () => {
<div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton />
</UserPreventSelfDelete>
</Toolbar>
@@ -272,35 +287,53 @@ const UserBooleanInput = props => {
const record = useRecordContext();
const ownUserId = localStorage.getItem("user_id");
let ownUserIsSelected = false;
if (record && record.id === ownUserId) {
ownUserIsSelected = true;
let asManagedUserIsSelected = false;
if (record) {
ownUserIsSelected = record.id === ownUserId;
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected} />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<BooleanInput {...props} disabled={ownUserIsSelected || asManagedUserIsSelected} />
</UserPreventSelfDelete>
);
};
const UserPasswordInput = props => {
const record = useRecordContext();
let asManagedUserIsSelected = false;
if (record) {
asManagedUserIsSelected = isASManaged(record.id);
}
return (
<PasswordInput {...props} helperText="resources.users.helper.modify_managed_user_error" disabled={asManagedUserIsSelected} />
);
};
export const UserEdit = (props: EditProps) => {
const translate = useTranslate();
return (
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />}>
<Edit {...props} title={<UserTitle />} actions={<UserEditActions />} mutationMode="pessimistic">
<TabbedForm toolbar={<UserEditToolbar />}>
<FormTab label={translate("resources.users.name", { smart_count: 1 })} icon={<PersonPinIcon />}>
<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
source="avatar_file"
label="resources.users.fields.avatar"
accept={{ "image/*": [".png", ".jpg"] }}
>
<ImageField source="src" title="Avatar" />
</ImageInput>
<TextInput source="id" disabled />
<TextInput source="id" readOnly />
<TextInput source="displayname" />
<PasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<UserPasswordInput source="password" autoComplete="new-password" helperText="resources.users.helper.password" />
<SelectInput source="user_type" choices={choices_type} translateChoice={false} resettable />
<BooleanInput source="admin" />
<BooleanInput source="locked" />
<UserBooleanInput source="locked" />
<UserBooleanInput source="deactivated" helperText="resources.users.helper.deactivate" />
<BooleanInput source="erased" disabled />
<DateField source="creation_ts_ms" showTime options={DATE_FORMAT} />
@@ -327,7 +360,7 @@ export const UserEdit = (props: EditProps) => {
<FormTab label={translate("resources.devices.name", { smart_count: 2 })} icon={<DevicesIcon />} path="devices">
<ReferenceManyField reference="devices" target="user_id" label={false}>
<Datagrid style={{ width: "100%" }}>
<Datagrid style={{ width: "100%" }} bulkActionButtons="">
<TextField source="device_id" sortable={false} />
<TextField source="display_name" sortable={false} />
<TextField source="last_seen_ip" sortable={false} />

View File

@@ -1,12 +1,12 @@
import { stringify } from "query-string";
import {
DataProvider,
DeleteParams,
HttpError,
Identifier,
Options,
PaginationPayload,
RaRecord,
SortPayload,
UpdateParams,
fetchUtils,
withLifecycleCallbacks,
@@ -52,6 +52,10 @@ const mxcUrlToHttp = (mxcUrl: string) => {
return `${homeserver}/_matrix/media/r0/thumbnail/${serverName}/${mediaId}?width=24&height=24&method=scale`;
};
const filterUndefined = (obj: Record<string, any>) => {
return Object.fromEntries(Object.entries(obj).filter(([key, value]) => value !== undefined));
};
interface Room {
room_id: string;
name?: string;
@@ -527,8 +531,8 @@ const baseDataProvider: SynapseDataProvider = {
getList: async (resource, params) => {
console.log("getList " + resource);
const { user_id, name, guests, deactivated, locked, search_term, destination, valid } = params.filter;
const { page, perPage } = params.pagination;
const { field, order } = params.sort;
const { page, perPage } = params.pagination as PaginationPayload;
const { field, order } = params.sort as SortPayload;
const from = (page - 1) * perPage;
const query = {
from: from,
@@ -550,7 +554,7 @@ const baseDataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const endpoint_url = homeserver + res.path;
const url = `${endpoint_url}?${stringify(query)}`;
const url = `${endpoint_url}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(url);
return {
@@ -604,7 +608,7 @@ const baseDataProvider: SynapseDataProvider = {
const res = resourceMap[resource];
const ref = res.reference(params.id);
const endpoint_url = `${homeserver}${ref.endpoint}?${stringify(query)}`;
const endpoint_url = `${homeserver}${ref.endpoint}?${new URLSearchParams(filterUndefined(query)).toString()}`;
const { json } = await jsonClient(endpoint_url);
return {

View File

@@ -24,7 +24,7 @@
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* Enable strict null checks. */
"strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */

14257
yarn.lock

File diff suppressed because it is too large Load Diff