Files
letro-authentication-service/frontend/src/components/UserEmail/UserEmail.tsx
2025-07-07 15:07:57 +02:00

203 lines
6.1 KiB
TypeScript

// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email";
import {
Button,
ErrorMessage,
Form,
IconButton,
Tooltip,
} from "@vector-im/compound-web";
import { type ReactNode, useCallback, useState } from "react";
import { Translation, useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlRequest } from "../../graphql";
import { Close, Description, Dialog, Title } from "../Dialog";
import LoadingSpinner from "../LoadingSpinner";
import PasswordConfirmationModal, {
usePasswordConfirmation,
} from "../PasswordConfirmation";
import styles from "./UserEmail.module.css";
// This component shows a single user email address, with controls to remove it
export const FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmail_email on UserEmail {
id
email
}
`);
const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation RemoveEmail($id: ID!, $password: String) {
removeEmail(input: { userEmailId: $id, password: $password }) {
status
user {
id
}
}
}
`);
const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
disabled,
onClick,
}) => (
<Translation>
{(t): ReactNode => (
<Tooltip label={t("frontend.user_email.delete_button_title")}>
<IconButton
type="button"
disabled={disabled}
className="m-2"
onClick={onClick}
size="var(--cpd-space-8x)"
>
<IconDelete className={styles.userEmailDeleteIcon} />
</IconButton>
</Tooltip>
)}
</Translation>
);
const UserEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
canRemove?: boolean;
shouldPromptPassword?: boolean;
onRemove?: () => void;
}> = ({ email, canRemove, shouldPromptPassword, onRemove }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const data = useFragment(FRAGMENT, email);
const queryClient = useQueryClient();
const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation();
const removeEmail = useMutation({
mutationFn: ({ id, password }: { id: string; password?: string }) =>
graphqlRequest({
query: REMOVE_EMAIL_MUTATION,
variables: { id, password },
}),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
// Don't close the modal unless the mutation was successful removed (or not found)
if (
data.removeEmail.status !== "NOT_FOUND" &&
data.removeEmail.status !== "REMOVED"
) {
return;
}
onRemove?.();
setOpen(false);
},
});
const onRemoveClick = useCallback(
async (_e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
let password: string | undefined;
if (shouldPromptPassword) {
password = await promptPassword();
}
removeEmail.mutate({ id: data.id, password });
},
[data.id, promptPassword, shouldPromptPassword, removeEmail.mutate],
);
const onOpenChange = useCallback(
(open: boolean) => {
// Don't change the modal state if the mutation is pending
if (removeEmail.isPending) return;
removeEmail.reset();
setOpen(open);
},
[removeEmail.isPending, removeEmail.reset],
);
const status = removeEmail.data?.removeEmail.status ?? null;
return (
<>
<PasswordConfirmationModal
title={t(
"frontend.user_email.delete_button_confirmation_modal.password_confirmation",
)}
destructive
ref={passwordConfirmationRef}
/>
<Form.Root>
<Form.Field name="email">
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
<div className="flex items-center gap-2">
<Form.TextControl
type="email"
readOnly
value={data.email}
className={styles.userEmailField}
/>
{canRemove && (
<Dialog
trigger={<DeleteButton />}
open={open}
onOpenChange={onOpenChange}
>
<Title>
{t(
"frontend.user_email.delete_button_confirmation_modal.body",
)}
</Title>
<Description className={styles.emailModalBox}>
<IconEmail />
<div>{data.email}</div>
</Description>
{status === "INCORRECT_PASSWORD" && (
<ErrorMessage>
{t(
"frontend.user_email.delete_button_confirmation_modal.incorrect_password",
)}
</ErrorMessage>
)}
<div className="flex flex-col gap-4">
<Button
kind="primary"
type="button"
destructive
onClick={onRemoveClick}
disabled={removeEmail.isPending}
Icon={removeEmail.isPending ? undefined : IconDelete}
>
{!!removeEmail.isPending && <LoadingSpinner inline />}
{t(
"frontend.user_email.delete_button_confirmation_modal.action",
)}
</Button>
<Close asChild>
<Button disabled={removeEmail.isPending} kind="tertiary">
{t("action.cancel")}
</Button>
</Close>
</div>
</Dialog>
)}
</div>
</Form.Field>
</Form.Root>
</>
);
};
export default UserEmail;