From b94b782547cc791593ff8c85311afadccc584f37 Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Tue, 12 Nov 2024 10:15:33 +0200 Subject: [PATCH] Prevent accidental user overwrites (#139) * Prevent accidental user overwrites * cleanup * update translation * update readme --- README.md | 1 + src/i18n/de.ts | 5 +++ src/i18n/en.ts | 5 +++ src/i18n/fa.ts | 5 +++ src/i18n/fr.ts | 5 +++ src/i18n/index.d.ts | 5 +++ src/i18n/it.ts | 5 +++ src/i18n/ru.ts | 5 +++ src/i18n/zh.ts | 5 +++ src/resources/users.tsx | 85 ++++++++++++++++++++++++++++++++++--- src/synapse/dataProvider.ts | 23 +++++++++- 11 files changed, 141 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 675fb52..4c95704 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ with a proper manifest.json generation on build) * [Add random password generation on user create/edit form](https://github.com/etkecc/synapse-admin/pull/123) * [Add option to set user's rate limits](https://github.com/etkecc/synapse-admin/pull/125) * [Support configuration via /.well-known/matrix/client](https://github.com/etkecc/synapse-admin/pull/126) +* [Prevent accidental user overwrites](https://github.com/etkecc/synapse-admin/pull/139) _the list will be updated as new changes are added_ diff --git a/src/i18n/de.ts b/src/i18n/de.ts index c107e9d..de80e7a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -184,6 +184,7 @@ const de: SynapseTranslationMessages = { erase_text: "Das bedeutet, dass die von dem/den Benutzer(n) gesendeten Nachrichten für alle, die zum Zeitpunkt des Sendens im Raum waren, sichtbar bleiben, aber für Benutzer, die dem Raum später beitreten, nicht sichtbar sind.", 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.", + username_available: "Benutzername verfügbar", }, action: { erase: "Lösche Benutzerdaten", @@ -191,6 +192,10 @@ const de: SynapseTranslationMessages = { delete_media: "Alle von dem/den Benutzer(n) hochgeladenen Medien löschen", redact_events: "Schwärzen aller vom Benutzer gesendeten Ereignisse (-s)", generate_password: "Passwort generieren", + overwrite_title: "Warnung!", + overwrite_content: "Dieser Benutzername ist bereits vergeben. Sind Sie sicher, dass Sie den vorhandenen Benutzer überschreiben möchten?", + overwrite_cancel: "Abbrechen", + overwrite_confirm: "Überschreiben", }, limits: { messages_per_second: "Nachrichten pro Sekunde", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 491d56a..3f3dd7c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -157,6 +157,7 @@ const en: SynapseTranslationMessages = { erase_text: "This means messages sent by the user(-s) will still be visible by anyone who was in the room when these messages were sent, but hidden from users joining the room afterward.", erase_admin_error: "Deleting own user is not allowed.", modify_managed_user_error: "Modifying a system-managed user is not allowed.", + username_available: "Username is available", }, action: { erase: "Erase user data", @@ -164,6 +165,10 @@ const en: SynapseTranslationMessages = { delete_media: "Delete all media uploaded by the user(-s)", redact_events: "Redact all events sent by the user(-s)", generate_password: "Generate password", + overwrite_title: "Warning!", + overwrite_content: "This username is already taken. Are you sure that you want to overwrite the existing user?", + overwrite_cancel: "Cancel", + overwrite_confirm: "Overwrite", }, limits: { messages_per_second: "Messages per second", diff --git a/src/i18n/fa.ts b/src/i18n/fa.ts index eca17c6..24174f3 100644 --- a/src/i18n/fa.ts +++ b/src/i18n/fa.ts @@ -149,6 +149,7 @@ const fa: SynapseTranslationMessages = { erase_text: "وهذا يعني أن الرسائل المرسلة من قبل المستخدم (المستخدمين) ستظل مرئية من قبل أي شخص كان في الغرفة عند إرسال هذه الرسائل، ولكنها مخفية عن المستخدمين الذين ينضمون إلى الغرفة بعد ذلك.", erase_admin_error: "حذف المستخدم الخاص غير مسموح به.", modify_managed_user_error: "لا يُسمح بتغيير المستخدم الذي يديره النظام.", + username_available: "نام کاربری موجود", }, action: { erase: "پاک کردن اطلاعات کاربر", @@ -156,6 +157,10 @@ const fa: SynapseTranslationMessages = { delete_media: "حذف جميع الوسائط التي تم تحميلها بواسطة المستخدم (المستخدمين)", redact_events: "تنقيح جميع الأحداث المرسلة من قبل المستخدم (-s)", generate_password: "توليد رمز عبور", + overwrite_title: "هشدار!", + overwrite_content: "این نام کاربری قبلا استفاده شده است. آیا مطمئن هستید که می خواهید کاربر موجود را بازنویسی کنید؟", + overwrite_cancel: "انصراف", + overwrite_confirm: "بازنویسی", }, limits: { messages_per_second: "پیام در ثانیه", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 939f3e2..d2b3feb 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -151,6 +151,7 @@ const fr: SynapseTranslationMessages = { erase_text: "Cela signifie que les messages envoyés par le(s) utilisateur(s) seront toujours visibles par toute personne qui se trouvait dans la salle au moment où ces messages ont été envoyés, mais qu'ils seront cachés aux utilisateurs qui rejoindront la salle par la suite.", 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.", + username_available: "Nom d'utilisateur disponible", }, action: { erase: "Effacer les données de l'utilisateur", @@ -158,6 +159,10 @@ const fr: SynapseTranslationMessages = { delete_media: "Supprimer tous les médias téléchargés par le(s) utilisateur(s)", redact_events: "Expurger tous les événements envoyés par l'utilisateur(-s)", generate_password: "Générer un mot de passe", + overwrite_title: "Attention !", + overwrite_content: "Ce nom d'utilisateur est déjà pris. Êtes-vous sûr de vouloir écraser l'utilisateur existant ?", + overwrite_cancel: "Annuler", + overwrite_confirm: "Écraser", }, limits: { messages_per_second: "Messages par seconde", diff --git a/src/i18n/index.d.ts b/src/i18n/index.d.ts index f41e560..cf9abce 100644 --- a/src/i18n/index.d.ts +++ b/src/i18n/index.d.ts @@ -149,6 +149,7 @@ interface SynapseTranslationMessages extends TranslationMessages { erase_text: string; erase_admin_error: string; modify_managed_user_error: string; + username_available: string; }; action: { erase: string; @@ -156,6 +157,10 @@ interface SynapseTranslationMessages extends TranslationMessages { delete_media: string; redact_events: string; generate_password: string; + overwrite_title: string; + overwrite_content: string; + overwrite_cancel: string; + overwrite_confirm: string; }; limits: { messages_per_second: string; diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 4e23369..6194a1e 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -150,6 +150,7 @@ const it: SynapseTranslationMessages = { erase_text: "Ciò significa che i messaggi inviati dall'utente (o dagli utenti) saranno ancora visibili da chiunque si trovasse nella stanza al momento dell'invio, ma saranno nascosti agli utenti che si uniranno alla stanza in seguito.", erase_admin_error: "Non è consentito eliminare il proprio utente.", modify_managed_user_error: "La modifica di un utente gestito dal sistema non è consentita.", + username_available: "Nome utente disponibile", }, action: { erase: "Cancella i dati dell'utente", @@ -157,6 +158,10 @@ const it: SynapseTranslationMessages = { delete_media: "Elimina tutti i media caricati dall'utente(-s)", redact_events: "Ridurre tutti gli eventi inviati dall'utente(-s)", generate_password: "Genera password", + overwrite_title: "Attenzione!", + overwrite_content: "Questo nome utente è già stato utilizzato. Sei sicuro di voler sovrascrivere l'utente esistente?", + overwrite_cancel: "Annulla", + overwrite_confirm: "Sovrascrivi", }, limits: { messages_per_second: "Messaggi al secondo", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 7ed9a32..19cb679 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -187,6 +187,7 @@ const ru: SynapseTranslationMessages = { erase_text: "Это означает, что сообщения, отправленные пользователем (-ами), будут по-прежнему видны всем, кто находился в комнате в момент их отправки, но будут скрыты от пользователей, присоединившихся к комнате после этого.", erase_admin_error: "Удаление собственного пользователя запрещено.", modify_managed_user_error: "Изменение пользователя, управляемого системой, не допускается.", + username_available: "Имя пользователя доступно", }, action: { erase: "Удалить данные пользователя", @@ -194,6 +195,10 @@ const ru: SynapseTranslationMessages = { delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)", redact_events: "Удаление всех событий, отправленных пользователем (-ами)", generate_password: "Сгенерировать пароль", + overwrite_title: "Предупреждение!", + overwrite_content: "Это имя пользователя уже занято. Вы уверены, что хотите перезаписать существующего пользователя?", + overwrite_cancel: "Отмена", + overwrite_confirm: "Перезаписать", }, limits: { messages_per_second: "Сообщений в секунду", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index cd4d8f0..1a00e39 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -174,6 +174,7 @@ const zh: SynapseTranslationMessages = { erase_text: "这意味着用户发送的信息对于发送信息时在房间内的任何人来说都是可见的,但对于之后加入房间的用户来说则是隐藏的。", erase_admin_error: "不允许删除自己的用户", modify_managed_user_error: "不允许修改系统管理的用户。", + username_available: "用户名可用", }, action: { erase: "抹除用户信息", @@ -181,6 +182,10 @@ const zh: SynapseTranslationMessages = { delete_media: "删除用户上传的所有媒体", redact_events: "重新编辑用户(-s)发送的所有事件", generate_password: "生成密码", + overwrite_title: "警告!", + overwrite_content: "这个用户名已经被占用。您确定要覆盖现有的用户吗?", + overwrite_cancel: "取消", + overwrite_confirm: "覆盖", }, limits: { messages_per_second: "每秒消息数", diff --git a/src/resources/users.tsx b/src/resources/users.tsx index fdd04d2..9e8bd0d 100644 --- a/src/resources/users.tsx +++ b/src/resources/users.tsx @@ -11,7 +11,8 @@ import ScienceIcon from "@mui/icons-material/Science"; import LockClockIcon from '@mui/icons-material/LockClock'; import ViewListIcon from "@mui/icons-material/ViewList"; import { useEffect, useState } from "react"; -import { Alert } from "@mui/material"; +import { Alert, Typography } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { ArrayInput, ArrayField, @@ -60,6 +61,9 @@ import { ImageField, FunctionField, useDataProvider, + Confirm, + useCreate, + useRedirect, } from "react-admin"; import { Link } from "react-router-dom"; @@ -74,6 +78,7 @@ import { generateRandomPassword } from "../synapse/synapse"; import { useFormContext } from "react-hook-form"; import { ExperimentalFeaturesList } from "../components/ExperimentalFeatures"; import { UserRateLimits } from "../components/UserRateLimits"; +import { User, UsernameAvailabilityResult } from "../synapse/dataProvider"; const choices_medium = [ { id: "email", name: "resources.users.email" }, @@ -213,13 +218,70 @@ const UserEditActions = () => { ); }; -export const UserCreate = (props: CreateProps) => ( - { + const dataProvider = useDataProvider(); + const translate = useTranslate(); + const redirect = useRedirect(); + const notify = useNotify(); + const theme = useTheme(); + + const [open, setOpen] = useState(false); + const [userIsAvailable, setUserIsAvailable] = useState(); + const [userAvailabilityEl, setUserAvailabilityEl] = useState(); + const [formData, setFormData] = useState>({}); + const [create] = useCreate(); + + const checkAvailability = async(event: React.FocusEvent) => { + const username = event.target.value; + const result: UsernameAvailabilityResult = await dataProvider.checkUsernameAvailability(username); + setUserIsAvailable(!!result?.available); + if (result?.available) { + setUserAvailabilityEl(✔️ {translate("resources.users.helper.username_available")}); + } else { + setUserAvailabilityEl(⚠️ {result?.error || "unknown error"}); + } + }; + + const postSave = (data: Record) => { + setFormData(data); + if (!userIsAvailable) { + setOpen(true); + return; + } + + create("users", { data: data }, { + onSuccess: (resource: User) => { + notify("ra.notification.created", { messageArgs: { smart_count: 1 } }); + redirect(() => { return `users/${resource.id}` }); + } + }); + }; + + const handleConfirm = () => { + setOpen(false); + updateUser(); + }; + + const handleDialogClose = () => { + setOpen(false); + }; + + const updateUser = () => { + create("users", { data: formData }, { + onSuccess: (resource: User) => { + notify("ra.notification.updated", { messageArgs: { smart_count: 1 } }); + redirect(() => { return `users/${resource.id}` }); + } + }); + } + + return `${resource}/${id}`} > - - + + @@ -237,8 +299,17 @@ export const UserCreate = (props: CreateProps) => ( + -); +}; const UserTitle = () => { const record = useRecordContext(); diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index 9537dca..bb030da 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -120,7 +120,8 @@ interface ExternalId { external_id: string; } -interface User { +export interface User { + id?: string; name: string; displayname?: string; threepids: Threepid[]; @@ -259,12 +260,19 @@ export interface RateLimitsModel { burst_count?: number; } +export interface UsernameAvailabilityResult { + available?: boolean; + error?: string; + errcode?: string; +} + export interface SynapseDataProvider extends DataProvider { deleteMedia: (params: DeleteMediaParams) => Promise; uploadMedia: (params: UploadMediaParams) => Promise; updateFeatures: (id: Identifier, features: ExperimentalFeaturesModel) => Promise; getRateLimits: (id: Identifier) => Promise; setRateLimits: (id: Identifier, rateLimits: RateLimitsModel) => Promise; + checkUsernameAvailability: (username: string) => Promise; } const resourceMap = { @@ -846,6 +854,19 @@ const baseDataProvider: SynapseDataProvider = { await jsonClient(endpoint_url, { method: "POST", body: JSON.stringify(filtered) }); }, + checkUsernameAvailability: async (username: string) => { + const base_url = storage.getItem("base_url"); + const endpoint_url = `${base_url}/_synapse/admin/v1/username_available?username=${encodeURIComponent(username)}`; + try { + const { json } = await jsonClient(endpoint_url); + return json as UsernameAvailabilityResult; + } catch (error) { + if (error instanceof HttpError) { + return { available: false, error: error.body.error, errcode: error.body.errcode } as UsernameAvailabilityResult; + } + throw error; + } + } }; const dataProvider = withLifecycleCallbacks(baseDataProvider, [