From 15972cff1de31998598fbdb90398a894e8ba652a Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 16 Jun 2026 13:24:37 +0200 Subject: [PATCH] change user's email address process --- csharp/App/Backend/Controller.cs | 52 +++++- csharp/App/Backend/DataTypes/Methods/User.cs | 35 ++++ csharp/App/Backend/Database/Db.cs | 15 ++ .../src/content/dashboards/Users/User.tsx | 158 +++++++++++++++++- typescript/frontend-marios2/src/lang/de.json | 7 + typescript/frontend-marios2/src/lang/en.json | 7 + typescript/frontend-marios2/src/lang/fr.json | 7 + typescript/frontend-marios2/src/lang/it.json | 7 + 8 files changed, 282 insertions(+), 6 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index b9476bbf1..04ee13000 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -891,13 +891,59 @@ public class Controller : ControllerBase [HttpPut(nameof(UpdateUser))] - public ActionResult UpdateUser([FromBody] User updatedUser, Token authToken) + public async Task> UpdateUser([FromBody] User updatedUser, Token authToken) { - var session = Db.GetSession(authToken); + var session = Db.GetSession(authToken); + var sessionUser = session?.User; - if (!session.Update(updatedUser)) + // Normalize: trim surrounding whitespace so a stray space can't cause a login mismatch. + updatedUser.Email = updatedUser.Email?.Trim(); + + // Capture the original row BEFORE the update — needed to detect an email change + // and to roll back if the set-password email can't be delivered. + var original = Db.GetUserById(updatedUser.Id); + var emailChanged = original is not null + && !String.Equals(original.Email, updatedUser.Email, StringComparison.OrdinalIgnoreCase); + + // An email change re-registers the account (resets the password + sends mail), so it is + // ADMIN-ONLY. Checked BEFORE session.Update so a non-admin's email change is never written + // (partners may still edit name/role/information of their own users — that path is unchanged). + if (emailChanged && sessionUser?.UserType != 2) // 2 == admin return Unauthorized(); + // Reject a malformed new email before anything is written (defense alongside the frontend check). + if (emailChanged && + !System.Text.RegularExpressions.Regex.IsMatch(updatedUser.Email ?? "", @"^[^\s@]+@[^\s@]+\.[^\s@]+$")) + return BadRequest("Invalid email format"); + + if (!session.Update(updatedUser)) // enforces permissions + email uniqueness; preserves parent/access + return Unauthorized(); + + if (emailChanged) + { + // Re-register: force the new address to set its own password via the welcome email. + // ACCEPTED LIMITATION: this is two DB writes (email via session.Update, then password + // here) plus an external email send, with no enclosing transaction (the codebase uses a + // single static connection and no transactions). A crash between the password clear and a + // successful send leaves the new email + an empty password (MustResetPassword=true). That + // state is recoverable by the admin re-applying the change, and the empty-password + + // MustResetPassword combination is the same posture as a freshly CreateUser'd account. + // We clear the password BEFORE sending (favouring the new owner's access) rather than + // after — sending first would, on a crash, lock the new owner out behind the old password. + Db.DeleteUserPassword(updatedUser); // Password="", MustResetPassword=true + + var mailSuccess = await Db.SendNewUserEmail(updatedUser); // set-password mail -> NEW email + if (!mailSuccess) + { + // Full revert of the entire row to its pre-edit state — this also discards any + // Name/Role/Information edits made in the same Apply, by design (all-or-nothing). + Db.Update(original!); + return StatusCode(500, "Set-password email failed to send; email change reverted"); + } + + await Db.SendEmailChangedReminder(updatedUser, original!.Email); // reminder -> OLD email, best-effort + } + return updatedUser.HidePassword(); } diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 331c44b44..408fdc9ef 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -298,6 +298,41 @@ public static class UserMethods return user.SendEmail(subject, body); } + public static Task SendEmailChangedNotice(this User user, String oldEmail) + { + var newEmail = user.Email; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + "Die E-Mail-Adresse Ihres inesco energy Kontos wurde geändert", + $"Sehr geehrte/r {user.Name}\n" + + $"Die E-Mail-Adresse Ihres inesco energy Kontos wurde auf {newEmail} geändert. " + + $"Falls Sie diese Änderung nicht veranlasst haben, wenden Sie sich bitte an das inesco energy Support-Team." + ), + "fr" => ( + "L'adresse e-mail de votre compte inesco energy a été modifiée", + $"Cher/Chère {user.Name}\n" + + $"L'adresse e-mail de votre compte inesco energy a été remplacée par {newEmail}. " + + $"Si vous n'êtes pas à l'origine de cette modification, veuillez contacter l'équipe d'assistance inesco energy." + ), + "it" => ( + "L'indirizzo email del tuo account inesco energy è stato modificato", + $"Gentile {user.Name}\n" + + $"L'indirizzo email del tuo account inesco energy è stato cambiato in {newEmail}. " + + $"Se non hai richiesto questa modifica, contatta il team di supporto inesco energy." + ), + _ => ( + "Your inesco energy account email was changed", + $"Dear {user.Name}\n" + + $"The email address for your inesco energy account was changed to {newEmail}. " + + $"If you did not request this change, please contact the inesco energy Support Team." + ) + }; + + return Mailer.Send(user.Name, oldEmail, subject, body); + } + public static Task SendTicketAssignedEmail(this User user, Ticket ticket) { var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index dfa906c85..e566fc96e 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -517,6 +517,21 @@ public static partial class Db } } + public static async Task SendEmailChangedReminder(User user, String oldEmail) + { + try + { + await user.SendEmailChangedNotice(oldEmail); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Email-changed reminder failed for {oldEmail}"); + Console.WriteLine(ex.ToString()); + return false; + } + } + public static Boolean DeleteUserPassword(User user) { user.Password = ""; diff --git a/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx b/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx index 78916083e..a7b080f80 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx @@ -42,6 +42,12 @@ function User(props: singleUserProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const [updated, setUpdated] = useState(false); + // Tracks whether the last successful save changed the email (re-registration banner). + const [emailChanged, setEmailChanged] = useState(false); + // Specific error message key for the email-change failure cases; null => generic error. + const [errorMsgId, setErrorMsgId] = useState(null); + // Confirmation dialog shown before an email change (resets password, emails both addresses). + const [openModalEmailChange, setOpenModalEmailChange] = useState(false); // Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it. const [searchParams, setSearchParams] = useSearchParams(); const [currentTab, setCurrentTab] = useState( @@ -111,7 +117,13 @@ function User(props: singleUserProps) { }); }; - const handleSubmit = async (e) => { + // Basic email-format guard (frontend half of the validation; backend enforces too). + const emailIsValid = (email: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((email ?? '').trim()); + + // Performs the actual update request. Called directly for non-email edits, or from + // the confirmation dialog when the email changed. + const doUpdate = async (didEmailChange: boolean) => { setLoading(true); setError(false); @@ -119,6 +131,18 @@ function User(props: singleUserProps) { .put(`/UpdateUser`, formValues) .catch((err) => { if (err.response) { + if (didEmailChange && err.response.status === 500) { + // Backend reverted the change because the set-password email failed. + setErrorMsgId('userEmailChangeFailedReverted'); + } else if (didEmailChange && err.response.status === 401) { + // Email already in use by another user, or not permitted (admins only). + setErrorMsgId('userEmailChangeNotAllowedOrTaken'); + } else if (didEmailChange && err.response.status === 400) { + // Backend rejected a malformed email (frontend normally catches this first). + setErrorMsgId('invalidEmailFormat'); + } else { + setErrorMsgId(null); + } setError(true); setLoading(false); } @@ -127,6 +151,7 @@ function User(props: singleUserProps) { if (res) { props.fetchDataAgain(); setLoading(false); + setEmailChanged(didEmailChange); setUpdated(true); setTimeout(() => { @@ -135,6 +160,37 @@ function User(props: singleUserProps) { } }; + const handleSubmit = async (e) => { + setError(false); + + // Compare against the originally-loaded email to detect a re-registration. + const didEmailChange = formValues.email !== props.current_user.email; + + // Reject a malformed new email before anything is sent (defense alongside backend guard). + if (didEmailChange && !emailIsValid(formValues.email)) { + setErrorMsgId('invalidEmailFormat'); + setError(true); + return; + } + + // An email change resets the password and emails both addresses — confirm first. + if (didEmailChange) { + setOpenModalEmailChange(true); + return; + } + + await doUpdate(false); + }; + + const emailChangeModalConfirm = async () => { + setOpenModalEmailChange(false); + await doUpdate(true); + }; + + const emailChangeModalCancel = () => { + setOpenModalEmailChange(false); + }; + const handleDelete = (e) => { setLoading(true); setError(false); @@ -254,6 +310,94 @@ function User(props: singleUserProps) { )} + {openModalEmailChange && ( + + + + + + + + {props.current_user.email} → {formValues.email} + + + + + + +
+ + +
+
+
+ )} + - An error has occurred + {errorMsgId ? ( + + ) : ( + + )} - Successfully updated + {emailChanged ? ( + + ) : ( + + )}