Skip to content
Open
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
28 changes: 28 additions & 0 deletions src/api/synapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,31 @@ export const scheduledTasksForResource = (
: false;
},
});

export const changeDisplayName = async (
queryClient: QueryClient,
synapseRoot: string,
mxid: string,
displayname: string,
signal?: AbortSignal,
): Promise<Response> => {
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;
};
178 changes: 169 additions & 9 deletions src/routes/_console.users.$userId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
KeyIcon,
LockIcon,
PlusIcon,
EditIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
Alert,
Expand All @@ -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";

Expand Down Expand Up @@ -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";
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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<HTMLFormElement>(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<void> {
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<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const displayname = formData.get("displayname") as string;
mutate(displayname);
},
[mutate],
);

return (
<Dialog.Root
open={open}
onOpenChange={onOpenChange}
trigger={
displayName ? (
<LabelAction Icon={EditIcon}>
<Text size="md" className="truncate">
{displayName}
</Text>
</LabelAction>
) : (
<Button kind="secondary" Icon={EditIcon} size="sm">
<FormattedMessage
id="pages.users.change_displayname.set_displayname_button"
defaultMessage="Set display name"
description="The label of the button for setting a user displayname"
/>
</Button>
)
}
>
<Dialog.Title>
<FormattedMessage
id="pages.users.change_displayname.title"
defaultMessage="Change a Display Name"
description="The title of the modal for changing a user displayname"
/>
</Dialog.Title>
<Dialog.Description>
<FormattedMessage
id="pages.users.change_displayname.description"
defaultMessage="Change a display name of this user"
description="The description of the modal for changing a user displayname"
/>
</Dialog.Description>
<Form.Root ref={formRef} onSubmit={handleSubmit}>
<Form.Field name="displayname" serverInvalid={isError}>
<Form.Label>
<FormattedMessage
id="pages.users.change_displayname.displayname_label"
defaultMessage="Display name"
description="Label for the displayname field in set displayname modal"
/>
</Form.Label>
<Form.TextControl
onInput={onDisplayNameInput}
disabled={isPending}
defaultValue={displayName}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
maxLength={256}
/>
{isError && <Form.ErrorMessage>{error.message}</Form.ErrorMessage>}
</Form.Field>
<Form.Submit disabled={isPending}>
{isPending && <InlineSpinner />}
<FormattedMessage
id="pages.users.change_displayname.submit"
defaultMessage="Change display name"
description="The submit button text in the set displayname modal"
/>
</Form.Submit>
</Form.Root>
<Dialog.Close asChild>
<Button
type="button"
kind="tertiary"
disabled={isPending}
onPointerDown={(e) => e.preventDefault()}
>
<FormattedMessage {...messages.actionCancel} />
</Button>
</Dialog.Close>
</Dialog.Root>
);
};

function RouteComponent() {
const { credentials } = Route.useRouteContext();
const { userId } = Route.useParams();
Expand All @@ -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),
);
Expand All @@ -1546,11 +1706,11 @@ function RouteComponent() {
<Text size="lg" weight="semibold" className="truncate">
{mxid}
</Text>
{displayName && (
<Text size="md" className="truncate">
{displayName}
</Text>
)}
<DisplayNameEdit
mxid={mxid}
synapseRoot={synapseRoot}
serverName={credentials.serverName}
/>
</div>
<AdminCheckbox
mxid={mxid}
Expand Down
25 changes: 25 additions & 0 deletions src/ui/label-action.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: Copyright 2026 just-doks
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
*/

.container {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}

.text {
grid-column: 2;
justify-self: center;
text-overflow: ellipsis;
max-width: 100%;
min-width: 0;
}

.icon {
grid-column: 3;
justify-self: start;
margin-left: 6px;
}
26 changes: 26 additions & 0 deletions src/ui/label-action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright 2026 just-doks
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial

import type { MouseEventHandler } from "react";
import { Button } from "@vector-im/compound-web";
import styles from "./label-action.module.css";

interface LabelActionProps {
Icon: React.ComponentType<React.SVGAttributes<SVGElement>>;
onClick?: MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
}
export const LabelAction = ({ Icon, onClick, children }: LabelActionProps) => (
<div className={styles["container"]}>
<div className={styles["text"]}>{children}</div>
<Button
className={styles["icon"]}
Icon={Icon}
iconOnly
size="sm"
kind="tertiary"
onClick={onClick}
/>
</div>
);
28 changes: 28 additions & 0 deletions translations/extracted/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down