diff --git a/src/api/synapse.ts b/src/api/synapse.ts index 75b7da64..58242b5c 100644 --- a/src/api/synapse.ts +++ b/src/api/synapse.ts @@ -365,3 +365,31 @@ export const scheduledTasksForResource = ( : false; }, }); + +export const changeDisplayName = async ( + queryClient: QueryClient, + synapseRoot: string, + mxid: string, + displayname: string, + signal?: AbortSignal, +): Promise => { + const url = new URL( + `/_synapse/admin/v2/users/${encodeURIComponent(mxid)}`, + synapseRoot, + ); + + const baseOptions_ = await baseOptions(queryClient, signal); + const response = await fetch(url, { + ...baseOptions_, + method: "PUT", + body: JSON.stringify({ displayname }), + headers: { + ...baseOptions_.headers, + "Content-Type": "application/json", + }, + }); + + await ensureNotError(response); + + return response; +}; diff --git a/src/routes/_console.users.$userId.tsx b/src/routes/_console.users.$userId.tsx index 3a132f18..7da5200e 100644 --- a/src/routes/_console.users.$userId.tsx +++ b/src/routes/_console.users.$userId.tsx @@ -18,6 +18,7 @@ import { KeyIcon, LockIcon, PlusIcon, + EditIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Alert, @@ -29,7 +30,7 @@ import { Text, Tooltip, } from "@vector-im/compound-web"; -import { useCallback, useRef, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { defineMessage, FormattedMessage, useIntl } from "react-intl"; @@ -57,6 +58,7 @@ import type { UserEmail, } from "@/api/mas/api"; import { profileQuery, wellKnownQuery } from "@/api/matrix"; +import { changeDisplayName } from "@/api/synapse"; import * as Data from "@/components/data"; import * as Dialog from "@/components/dialog"; import { ButtonLink } from "@/components/link"; @@ -65,6 +67,7 @@ import { UserAvatar } from "@/components/room-info"; import * as messages from "@/messages"; import { computeHumanReadableDateTimeStringFromUtc } from "@/utils/datetime"; import { ensureParametersAreUlids } from "@/utils/parameters"; +import { LabelAction } from "@/ui/label-action"; export const Route = createFileRoute("/_console/users/$userId")({ loader: async ({ context: { queryClient, credentials }, params }) => { @@ -1510,6 +1513,166 @@ const CloseSidebar: React.FC = () => { ); }; +interface DisplayNameEditProps { + serverName: string; + synapseRoot: string; + mxid: string; +} +const DisplayNameEdit = ({ + serverName, + synapseRoot, + mxid, +}: DisplayNameEditProps) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + const [open, setOpen] = useState(false); + const formRef = useRef(null); + const { data: profile } = useQuery(profileQuery(synapseRoot, mxid)); + const displayName = profile?.displayname; + + const { mutate, isPending, error, isError, reset } = useMutation({ + mutationFn: (displayname: string) => + changeDisplayName(queryClient, synapseRoot, mxid, displayname), + onError: () => { + toast.error( + intl.formatMessage({ + id: "pages.users.change_displayname.error", + defaultMessage: "Failed to change display name", + description: "The error message for changing a user displayname", + }), + ); + }, + async onSuccess(): Promise { + toast.success( + intl.formatMessage({ + id: "pages.users.change_displayname.success", + defaultMessage: "Display name changed", + description: "The success message for changing a user displayname", + }), + ); + + // // Invalidate both the individual user query and the users list + queryClient.invalidateQueries({ + queryKey: ["mas", "users", serverName], + }); + + // Await on the individual user invalidation query invalidation so that + // the query stays in a pending state until the new data is loaded + await queryClient.invalidateQueries({ + queryKey: profileQuery(synapseRoot, mxid).queryKey, + }); + + setOpen(false); + }, + }); + + const onOpenChange = useCallback( + (open: boolean) => { + if (isPending) { + return; + } + setOpen(open); + if (!open) { + formRef.current?.reset(); + reset(); + } + }, + [isPending, reset], + ); + + const onDisplayNameInput = useCallback(() => { + if (isError) reset(); + }, [isError, reset]); + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const displayname = formData.get("displayname") as string; + mutate(displayname); + }, + [mutate], + ); + + return ( + + + {displayName} + + + ) : ( + + ) + } + > + + + + + + + + + + + + + {isError && {error.message}} + + + {isPending && } + + + + + + + + ); +}; + function RouteComponent() { const { credentials } = Route.useRouteContext(); const { userId } = Route.useParams(); @@ -1525,9 +1688,6 @@ function RouteComponent() { // TODO: this should be in a helper const mxid = `@${user.attributes.username}:${credentials.serverName}`; - const { data: profile } = useQuery(profileQuery(synapseRoot, mxid)); - const displayName = profile?.displayname; - const { data: siteConfig } = useSuspenseQuery( siteConfigQuery(credentials.serverName), ); @@ -1546,11 +1706,11 @@ function RouteComponent() { {mxid} - {displayName && ( - - {displayName} - - )} + >; + onClick?: MouseEventHandler; + children: React.ReactNode; +} +export const LabelAction = ({ Icon, onClick, children }: LabelActionProps) => ( +
+
{children}
+
+); diff --git a/translations/extracted/en.json b/translations/extracted/en.json index f207f106..0e3253dd 100644 --- a/translations/extracted/en.json +++ b/translations/extracted/en.json @@ -891,6 +891,34 @@ "description": "The success message for adding an upstream link", "message": "Link to upstream account added" }, + "pages.users.change_displayname.description": { + "description": "The description of the modal for changing a user displayname", + "message": "Change a display name of this user" + }, + "pages.users.change_displayname.displayname_label": { + "description": "Label for the displayname field in set displayname modal", + "message": "Display name" + }, + "pages.users.change_displayname.error": { + "description": "The error message for changing a user displayname", + "message": "Failed to change display name" + }, + "pages.users.change_displayname.set_displayname_button": { + "description": "The label of the button for setting a user displayname", + "message": "Set display name" + }, + "pages.users.change_displayname.submit": { + "description": "The submit button text in the set displayname modal", + "message": "Change display name" + }, + "pages.users.change_displayname.success": { + "description": "The success message for changing a user displayname", + "message": "Display name changed" + }, + "pages.users.change_displayname.title": { + "description": "The title of the modal for changing a user displayname", + "message": "Change a Display Name" + }, "pages.users.deactivate_account.alert.description": { "description": "In the modal to deactivate a user, the description of the alert", "message": "This will automatically sign the user out of all devices, remove any access tokens, delete all third-party IDs, and permanently erase their account data."