diff --git a/README.md b/README.md index 856f525..799f841 100644 --- a/README.md +++ b/README.md @@ -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_ @@ -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. @@ -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) diff --git a/src/AppContext.tsx b/src/AppContext.tsx index 1a3d83b..6426b61 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext } from "react"; interface AppContextType { restrictBaseUrl: string | string[]; + asManagedUsers: string[]; } export const AppContext = createContext({}); diff --git a/src/components/devices.tsx b/src/components/devices.tsx index 3165a40..4661856 100644 --- a/src/components/devices.tsx +++ b/src/components/devices.tsx @@ -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 ( { 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, diff --git a/src/components/mxid.tsx b/src/components/mxid.tsx new file mode 100644 index 0000000..5f2e209 --- /dev/null +++ b/src/components/mxid.tsx @@ -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; + } +}; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 4661eeb..5118f4f 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c3de63c..9bbc2e6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 0506243..a331309 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -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", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 59b3ce5..c4dfc04 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -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: { diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 1bd4056..6e7b79f 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -160,6 +160,7 @@ const ru: SynapseTranslationMessages = { deactivate: "Вы должны предоставить пароль для реактивации учётной записи.", erase: "Пометить пользователя как удалённого в соответствии с GDPR", erase_admin_error: "Удаление собственного пользователя запрещено.", + erase_managed_user_error: "Удаление управляемого системой пользователя запрещено.", }, action: { erase: "Удалить данные пользователя", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 75caeba..b07123e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -144,6 +144,7 @@ const zh: SynapseTranslationMessages = { deactivate: "您必须提供一串密码来激活账户。", erase: "将用户标记为根据 GDPR 的要求抹除了", erase_admin_error: "不允许删除自己的用户", + erase_managed_user_error: "不允许删除受管理的用户", }, action: { erase: "抹除用户信息", diff --git a/src/index.tsx b/src/index.tsx index 6819265..7e60fb5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( ) - ); + }); diff --git a/src/resources/users.tsx b/src/resources/users.tsx index f7eb2ca..90058e9 100644 --- a/src/resources/users.tsx +++ b/src/resources/users.tsx @@ -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 = [ , ]; -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({translate("resources.users.helper.erase_admin_error")}); ev.stopPropagation(); + } else if (asManagedUserIsSelected) { + notify({translate("resources.users.helper.erase_managed_user_error")}); + 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 ( <> - + { 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 ( {!record?.deactivated && } - + ( const UserTitle = () => { const record = useRecordContext(); const translate = useTranslate(); + let username = record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : "" + if (isASManaged(record?.id)) { + username += " 🤖"; + } return ( {translate("resources.users.name", { smart_count: 1, })}{" "} - {record ? (record.displayname ? `"${record.displayname}"` : `"${record.name}"`) : ""} + {username} ); }; @@ -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 = () => {
- + @@ -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 ( - - + + ); }; +const UserPasswordInput = props => { + const record = useRecordContext(); + let asManagedUserIsSelected = false; + if (record) { + asManagedUserIsSelected = isASManaged(record.id); + } + + return ( + + ); +}; + export const UserEdit = (props: EditProps) => { const translate = useTranslate(); @@ -301,10 +330,10 @@ export const UserEdit = (props: EditProps) => { - + - + @@ -331,7 +360,7 @@ export const UserEdit = (props: EditProps) => { } path="devices"> - +