Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 49 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The following changes are already implemented:
* [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_

Expand Down Expand Up @@ -126,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.
Expand Down Expand Up @@ -194,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)
Expand Down
1 change: 1 addition & 0 deletions src/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createContext, useContext } from "react";

interface AppContextType {
restrictBaseUrl: string | string[];
asManagedUsers: string[];
}

export const AppContext = createContext({});
Expand Down
7 changes: 7 additions & 0 deletions src/components/devices.tsx
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/components/mxid.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
};
1 change: 1 addition & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ const de: SynapseTranslationMessages = {
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_managed_user_error: "Die Löschung eines vom System verwalteten Benutzers ist nicht zulässig",
},
action: {
erase: "Lösche Benutzerdaten",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
erase_managed_user_error: "Deleting a system-managed user is not allowed.",
},
action: {
erase: "Erase user data",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
erase_managed_user_error: "La suppression d'un utilisateur géré n'est pas autorisée.",
},
action: {
erase: "Effacer les données de l'utilisateur",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const it: SynapseTranslationMessages = {
action: {
erase: "Cancella i dati dell'utente",
erase_admin_error: "Non è consentito eliminare il proprio utente.",
erase_managed_user_error: "Non è consentito eliminare un utente gestito.",
},
},
rooms: {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const ru: SynapseTranslationMessages = {
deactivate: "Вы должны предоставить пароль для реактивации учётной записи.",
erase: "Пометить пользователя как удалённого в соответствии с GDPR",
erase_admin_error: "Удаление собственного пользователя запрещено.",
erase_managed_user_error: "Удаление управляемого системой пользователя запрещено.",
},
action: {
erase: "Удалить данные пользователя",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const zh: SynapseTranslationMessages = {
deactivate: "您必须提供一串密码来激活账户。",
erase: "将用户标记为根据 GDPR 的要求抹除了",
erase_admin_error: "不允许删除自己的用户",
erase_managed_user_error: "不允许删除受管理的用户",
},
action: {
erase: "抹除用户信息",
Expand Down
8 changes: 5 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
);
});
53 changes: 41 additions & 12 deletions src/resources/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,15 +104,19 @@ 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();

const handleDeleteClick = (ev: React.MouseEvent<HTMLDivElement>) => {
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.erase_managed_user_error")}</Alert>);
ev.stopPropagation();
}
};

Expand All @@ -121,19 +126,21 @@ 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();
const translate = useTranslate();

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"
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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>
);
};
Expand All @@ -250,16 +263,18 @@ 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 (
<>
<div className={ToolbarClasses.defaultToolbar}>
<Toolbar sx={{ justifyContent: "space-between" }}>
<SaveButton />
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected}>
<UserPreventSelfDelete ownUserIsSelected={ownUserIsSelected} asManagedUserIsSelected={asManagedUserIsSelected}>
<DeleteButton />
</UserPreventSelfDelete>
</Toolbar>
Expand All @@ -272,17 +287,31 @@ 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.erase_managed_user_error" disabled={asManagedUserIsSelected} />
);
};

export const UserEdit = (props: EditProps) => {
const translate = useTranslate();

Expand All @@ -301,10 +330,10 @@ export const UserEdit = (props: EditProps) => {
</ImageInput>
<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} />
Expand All @@ -331,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} />
Expand Down