@@ -62,7 +62,6 @@ import {
6262 THEME_OPTIONS_BY_VALUE ,
6363 themeOptionIcon ,
6464} from "~/components/themeOptions" ;
65- import { prisma } from "~/db.server" ;
6665import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server" ;
6766import {
6867 applyThemeContrast ,
@@ -72,9 +71,14 @@ import {
7271} from "~/hooks/useSystemThemeSync" ;
7372import { useFeatures } from "~/hooks/useFeatures" ;
7473import { useHasAdminAccess , useUser } from "~/hooks/useUser" ;
75- import { updateUserEmail , updateUserMarketingEmails , updateUserName } from "~/models/user.server" ;
74+ import { updateUserMarketingEmails , updateUserName } from "~/models/user.server" ;
75+ import {
76+ cancelEmailChange ,
77+ requestEmailChange ,
78+ resendEmailChange ,
79+ } from "~/services/emailChange.server" ;
7680import { logger } from "~/services/logger.server" ;
77- import { type EmailOwnership , getEmailOwnership } from "~/services/ssoManagedIdentity.server" ;
81+ import { type EmailOwnership } from "~/services/ssoManagedIdentity.server" ;
7882import {
7983 updateContrastPreference ,
8084 updateIconContrastPreference ,
@@ -370,27 +374,25 @@ export const action: ActionFunction = async ({ request }) => {
370374 ) ;
371375 }
372376
373- const { email } = submission . data ;
377+ const result = await requestEmailChange ( gate . user , submission . data . email ) ;
378+ if ( ! result . ok ) return profileUpdateError ( result . error , result . status ) ;
379+ return json ( { success : true as const } ) ;
380+ }
374381
375- const ownership = await getEmailOwnership ( gate . user , email ) ;
376- if ( ownership === "idp" ) {
377- return profileUpdateError (
378- "Your email address is managed by your organization's identity provider." ,
379- 403
380- ) ;
381- }
382- if ( ownership === "unknown" ) {
383- return profileUpdateError (
384- "We couldn't check your single sign-on settings just now. Please try again shortly." ,
385- 503
386- ) ;
387- }
388- const existingUser = await prisma . user . findFirst ( { where : { email } } ) ;
389- if ( existingUser && existingUser . id !== gate . user . id ) {
390- return profileUpdateError ( "Email is already being used by a different account" , 400 ) ;
391- }
382+ if ( formData . get ( "action" ) === "resend-email-change" ) {
383+ const gate = await requireOwnAccountWrite ( request ) ;
384+ if ( "error" in gate ) return gate . error ;
392385
393- await updateUserEmail ( { id : gate . user . id , email } ) ;
386+ const result = await resendEmailChange ( gate . user ) ;
387+ if ( ! result . ok ) return profileUpdateError ( result . error , result . status ) ;
388+ return json ( { success : true as const } ) ;
389+ }
390+
391+ if ( formData . get ( "action" ) === "cancel-email-change" ) {
392+ const gate = await requireOwnAccountWrite ( request ) ;
393+ if ( "error" in gate ) return gate . error ;
394+
395+ await cancelEmailChange ( gate . user . id ) ;
394396 return json ( { success : true as const } ) ;
395397 }
396398
@@ -528,7 +530,7 @@ function EditEmailButton() {
528530 const user = useUser ( ) ;
529531 const [ isOpen , setIsOpen ] = useState ( false ) ;
530532 const { fetcher, error, setError, isSubmitting } = useProfileFieldUpdate ( {
531- successMessage : "Your email address has been updated ." ,
533+ successMessage : "Check your inbox for a link to confirm your new email address ." ,
532534 onSuccess : ( ) => setIsOpen ( false ) ,
533535 } ) ;
534536 const ownershipFetcher = useFetcher < { ownership : EmailOwnership } > ( ) ;
@@ -601,7 +603,7 @@ function EditEmailButton() {
601603 Cancel
602604 </ Button >
603605 < Button type = "submit" variant = "primary/medium" disabled = { isSubmitting } >
604- { isSubmitting ? "Saving …" : "Update " }
606+ { isSubmitting ? "Sending …" : "Send confirmation link " }
605607 </ Button >
606608 </ DialogFooter >
607609 </ fetcher . Form >
@@ -612,6 +614,40 @@ function EditEmailButton() {
612614 ) ;
613615}
614616
617+ /** Shown under the email row while a new address is waiting to be confirmed. */
618+ function PendingEmailChange ( { pendingEmail } : { pendingEmail : string } ) {
619+ const resend = useProfileFieldUpdate ( {
620+ successMessage : `We've sent another confirmation link to ${ pendingEmail } .` ,
621+ onSuccess : ( ) => { } ,
622+ } ) ;
623+ const cancel = useProfileFieldUpdate ( {
624+ successMessage : "Email change cancelled." ,
625+ onSuccess : ( ) => { } ,
626+ } ) ;
627+
628+ return (
629+ < div className = "flex w-full items-center justify-between gap-4 pb-3" >
630+ < Paragraph variant = "extra-small" className = "min-w-0 break-all text-text-dimmed" >
631+ Confirm { pendingEmail } using the link we emailed to it.
632+ </ Paragraph >
633+ < div className = "flex flex-none items-center gap-2" >
634+ < resend . fetcher . Form method = "post" >
635+ < input type = "hidden" name = "action" value = "resend-email-change" />
636+ < Button type = "submit" variant = "tertiary/small" disabled = { resend . isSubmitting } >
637+ Resend
638+ </ Button >
639+ </ resend . fetcher . Form >
640+ < cancel . fetcher . Form method = "post" >
641+ < input type = "hidden" name = "action" value = "cancel-email-change" />
642+ < Button type = "submit" variant = "tertiary/small" disabled = { cancel . isSubmitting } >
643+ Cancel
644+ </ Button >
645+ </ cancel . fetcher . Form >
646+ </ div >
647+ </ div >
648+ ) ;
649+ }
650+
615651const MARKETING_EMAILS_DEBOUNCE_MS = 600 ;
616652
617653/** Debounced, one write at a time; the action's rate limit backs it up. */
@@ -988,8 +1024,8 @@ export default function Page() {
9881024 </ div >
9891025 </ div >
9901026 </ div >
991- < div className = "flex min-h-16 w-full items -center border-b border-grid-dimmed" >
992- < div className = "flex w-full items-center justify-between gap-4" >
1027+ < div className = "flex min-h-16 w-full flex-col justify -center border-b border-grid-dimmed" >
1028+ < div className = "flex w-full items-center justify-between gap-4 py-3 " >
9931029 < Label > Email address</ Label >
9941030 < div className = "flex min-w-0 items-center gap-3" >
9951031 { /* break-all: an address has no spaces to wrap at, so a long one
@@ -1000,6 +1036,7 @@ export default function Page() {
10001036 < EditEmailButton />
10011037 </ div >
10021038 </ div >
1039+ { user . pendingEmail && < PendingEmailChange pendingEmail = { user . pendingEmail } /> }
10031040 </ div >
10041041 < div className = "flex min-h-16 w-full items-center border-b border-grid-dimmed" >
10051042 < div className = "flex w-full items-center justify-between gap-4" >
0 commit comments