77import { useMutation , useQueryClient } from "@tanstack/react-query" ;
88import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete" ;
99import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email" ;
10- import { Button , Form , IconButton , Tooltip } from "@vector-im/compound-web" ;
11- import type { ComponentProps , ReactNode } from "react" ;
10+ import {
11+ Button ,
12+ ErrorMessage ,
13+ Form ,
14+ IconButton ,
15+ Tooltip ,
16+ } from "@vector-im/compound-web" ;
17+ import { type ReactNode , useCallback , useState } from "react" ;
1218import { Translation , useTranslation } from "react-i18next" ;
1319import { type FragmentType , graphql , useFragment } from "../../gql" ;
1420import { graphqlRequest } from "../../graphql" ;
1521import { Close , Description , Dialog , Title } from "../Dialog" ;
22+ import LoadingSpinner from "../LoadingSpinner" ;
23+ import PasswordConfirmationModal , {
24+ usePasswordConfirmation ,
25+ } from "../PasswordConfirmation" ;
1626import styles from "./UserEmail.module.css" ;
1727
18- // This component shows a single user email address, with controls to verify it,
19- // resend the verification email, remove it, and set it as the primary email address.
28+ // This component shows a single user email address, with controls to remove it
2029
2130export const FRAGMENT = graphql ( /* GraphQL */ `
2231 fragment UserEmail_email on UserEmail {
@@ -25,15 +34,9 @@ export const FRAGMENT = graphql(/* GraphQL */ `
2534 }
2635` ) ;
2736
28- export const CONFIG_FRAGMENT = graphql ( /* GraphQL */ `
29- fragment UserEmail_siteConfig on SiteConfig {
30- emailChangeAllowed
31- }
32- ` ) ;
33-
3437const REMOVE_EMAIL_MUTATION = graphql ( /* GraphQL */ `
35- mutation RemoveEmail($id: ID!) {
36- removeEmail(input: { userEmailId: $id }) {
38+ mutation RemoveEmail($id: ID!, $password: String ) {
39+ removeEmail(input: { userEmailId: $id, password: $password }) {
3740 status
3841
3942 user {
@@ -64,92 +67,135 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
6467 </ Translation >
6568) ;
6669
67- const DeleteButtonWithConfirmation : React . FC <
68- ComponentProps < typeof DeleteButton > & { email : string }
69- > = ( { email, onClick, ...rest } ) => {
70- const { t } = useTranslation ( ) ;
71- const onConfirm = ( ) : void => {
72- onClick ?.( ) ;
73- } ;
74-
75- // NOOP function, otherwise we dont render a cancel button
76- const onDeny = ( ) : void => { } ;
77-
78- return (
79- < Dialog trigger = { < DeleteButton { ...rest } /> } >
80- < Title >
81- { t ( "frontend.user_email.delete_button_confirmation_modal.body" ) }
82- </ Title >
83- < Description className = { styles . emailModalBox } >
84- < IconEmail />
85- < div > { email } </ div >
86- </ Description >
87- < div className = "flex flex-col gap-4" >
88- < Close asChild >
89- < Button
90- kind = "primary"
91- destructive
92- onClick = { onConfirm }
93- Icon = { IconDelete }
94- >
95- { t ( "frontend.user_email.delete_button_confirmation_modal.action" ) }
96- </ Button >
97- </ Close >
98- < Close asChild >
99- < Button kind = "tertiary" onClick = { onDeny } >
100- { t ( "action.cancel" ) }
101- </ Button >
102- </ Close >
103- </ div >
104- </ Dialog >
105- ) ;
106- } ;
107-
10870const UserEmail : React . FC < {
10971 email : FragmentType < typeof FRAGMENT > ;
11072 canRemove ?: boolean ;
73+ shouldPromptPassword ?: boolean ;
11174 onRemove ?: ( ) => void ;
112- } > = ( { email, canRemove, onRemove } ) => {
75+ } > = ( { email, canRemove, shouldPromptPassword , onRemove } ) => {
11376 const { t } = useTranslation ( ) ;
77+ const [ open , setOpen ] = useState ( false ) ;
11478 const data = useFragment ( FRAGMENT , email ) ;
11579 const queryClient = useQueryClient ( ) ;
80+ const [ promptPassword , passwordConfirmationRef ] = usePasswordConfirmation ( ) ;
11681
11782 const removeEmail = useMutation ( {
118- mutationFn : ( id : string ) =>
119- graphqlRequest ( { query : REMOVE_EMAIL_MUTATION , variables : { id } } ) ,
120- onSuccess : ( _data ) => {
121- onRemove ?.( ) ;
83+ mutationFn : ( { id, password } : { id : string ; password ?: string } ) =>
84+ graphqlRequest ( {
85+ query : REMOVE_EMAIL_MUTATION ,
86+ variables : { id, password } ,
87+ } ) ,
88+
89+ onSuccess : ( data ) => {
12290 queryClient . invalidateQueries ( { queryKey : [ "currentUserGreeting" ] } ) ;
12391 queryClient . invalidateQueries ( { queryKey : [ "userEmails" ] } ) ;
92+
93+ // Don't close the modal unless the mutation was successful removed (or not found)
94+ if (
95+ data . removeEmail . status !== "NOT_FOUND" &&
96+ data . removeEmail . status !== "REMOVED"
97+ ) {
98+ return ;
99+ }
100+
101+ onRemove ?.( ) ;
102+ setOpen ( false ) ;
124103 } ,
125104 } ) ;
126105
127- const onRemoveClick = ( ) : void => {
128- removeEmail . mutate ( data . id ) ;
129- } ;
106+ const onRemoveClick = useCallback (
107+ async ( _e : React . MouseEvent < HTMLButtonElement > ) : Promise < void > => {
108+ let password = undefined ;
109+ if ( shouldPromptPassword ) {
110+ password = await promptPassword ( ) ;
111+ }
112+ removeEmail . mutate ( { id : data . id , password } ) ;
113+ } ,
114+ [ data . id , promptPassword , shouldPromptPassword , removeEmail . mutate ] ,
115+ ) ;
116+
117+ const onOpenChange = useCallback (
118+ ( open : boolean ) => {
119+ // Don't change the modal state if the mutation is pending
120+ if ( removeEmail . isPending ) return ;
121+ removeEmail . reset ( ) ;
122+ setOpen ( open ) ;
123+ } ,
124+ [ removeEmail . isPending , removeEmail . reset ] ,
125+ ) ;
126+
127+ const status = removeEmail . data ?. removeEmail . status ?? null ;
130128
131129 return (
132- < Form . Root >
133- < Form . Field name = "email" >
134- < Form . Label > { t ( "frontend.user_email.email" ) } </ Form . Label >
135-
136- < div className = "flex items-center gap-2" >
137- < Form . TextControl
138- type = "email"
139- readOnly
140- value = { data . email }
141- className = { styles . userEmailField }
142- />
143- { canRemove && (
144- < DeleteButtonWithConfirmation
145- email = { data . email }
146- disabled = { removeEmail . isPending }
147- onClick = { onRemoveClick }
130+ < >
131+ < PasswordConfirmationModal
132+ title = { t (
133+ "frontend.user_email.delete_button_confirmation_modal.password_confirmation" ,
134+ ) }
135+ destructive
136+ ref = { passwordConfirmationRef }
137+ />
138+ < Form . Root >
139+ < Form . Field name = "email" >
140+ < Form . Label > { t ( "frontend.user_email.email" ) } </ Form . Label >
141+
142+ < div className = "flex items-center gap-2" >
143+ < Form . TextControl
144+ type = "email"
145+ readOnly
146+ value = { data . email }
147+ className = { styles . userEmailField }
148148 />
149- ) }
150- </ div >
151- </ Form . Field >
152- </ Form . Root >
149+ { canRemove && (
150+ < Dialog
151+ trigger = { < DeleteButton /> }
152+ open = { open }
153+ onOpenChange = { onOpenChange }
154+ >
155+ < Title >
156+ { t (
157+ "frontend.user_email.delete_button_confirmation_modal.body" ,
158+ ) }
159+ </ Title >
160+ < Description className = { styles . emailModalBox } >
161+ < IconEmail />
162+ < div > { data . email } </ div >
163+ </ Description >
164+
165+ { status === "INCORRECT_PASSWORD" && (
166+ < ErrorMessage >
167+ { t (
168+ "frontend.user_email.delete_button_confirmation_modal.incorrect_password" ,
169+ ) }
170+ </ ErrorMessage >
171+ ) }
172+
173+ < div className = "flex flex-col gap-4" >
174+ < Button
175+ kind = "primary"
176+ type = "button"
177+ destructive
178+ onClick = { onRemoveClick }
179+ disabled = { removeEmail . isPending }
180+ Icon = { removeEmail . isPending ? undefined : IconDelete }
181+ >
182+ { ! ! removeEmail . isPending && < LoadingSpinner inline /> }
183+ { t (
184+ "frontend.user_email.delete_button_confirmation_modal.action" ,
185+ ) }
186+ </ Button >
187+ < Close asChild >
188+ < Button disabled = { removeEmail . isPending } kind = "tertiary" >
189+ { t ( "action.cancel" ) }
190+ </ Button >
191+ </ Close >
192+ </ div >
193+ </ Dialog >
194+ ) }
195+ </ div >
196+ </ Form . Field >
197+ </ Form . Root >
198+ </ >
153199 ) ;
154200} ;
155201
0 commit comments