change user's email address process

This commit is contained in:
Yinyin Liu 2026-06-16 13:24:37 +02:00
parent 7654191ea6
commit 15972cff1d
8 changed files with 282 additions and 6 deletions

View File

@ -891,13 +891,59 @@ public class Controller : ControllerBase
[HttpPut(nameof(UpdateUser))]
public ActionResult<User> UpdateUser([FromBody] User updatedUser, Token authToken)
public async Task<ActionResult<User>> 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();
}

View File

@ -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}";

View File

@ -517,6 +517,21 @@ public static partial class Db
}
}
public static async Task<Boolean> 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 = "";

View File

@ -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<string | null>(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<string>(
@ -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) {
</Modal>
)}
{openModalEmailChange && (
<Modal
open={openModalEmailChange}
onClose={emailChangeModalCancel}
aria-labelledby="email-change-modal"
aria-describedby="email-change-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
variant="body1"
gutterBottom
sx={{ fontWeight: 'bold' }}
>
<FormattedMessage
id="emailChangeConfirmTitle"
defaultMessage="Change this user's email?"
/>
</Typography>
<Typography
variant="body2"
sx={{ mt: 1, textAlign: 'center', wordBreak: 'break-all' }}
>
{props.current_user.email} {formValues.email}
</Typography>
<Typography
variant="body2"
sx={{ mt: 2, textAlign: 'center' }}
>
<FormattedMessage
id="emailChangeConfirmWarning"
defaultMessage="This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address."
/>
</Typography>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalConfirm}
>
<FormattedMessage id="confirm" defaultMessage="Confirm" />
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={emailChangeModalCancel}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
</Modal>
)}
<Grid item xs={12} md={12}>
<TabsContainerWrapper>
<Tabs
@ -405,7 +549,11 @@ function User(props: singleUserProps) {
alignItems: 'center'
}}
>
An error has occurred
{errorMsgId ? (
<FormattedMessage id={errorMsgId} />
) : (
<FormattedMessage id="errorOccured" />
)}
<IconButton
color="inherit"
size="small"
@ -425,7 +573,11 @@ function User(props: singleUserProps) {
alignItems: 'center'
}}
>
Successfully updated
{emailChanged ? (
<FormattedMessage id="userEmailChangedSetPasswordSent" />
) : (
<FormattedMessage id="successfullyUpdated" />
)}
<IconButton
color="inherit"
size="small"

View File

@ -538,6 +538,13 @@
"forgotPasswordLink": "Passwort vergessen?",
"provideYourUsername": "Geben Sie Ihren Benutzernamen ein",
"userName": "Benutzername",
"userEmailChangedSetPasswordSent": "E-Mail aktualisiert. Eine E-Mail zum Festlegen des Passworts wurde an die neue Adresse gesendet, die bisherige Adresse wurde benachrichtigt.",
"userEmailChangeFailedReverted": "Die E-Mail zum Festlegen des Passworts konnte nicht gesendet werden — die Änderung wurde rückgängig gemacht.",
"userEmailChangeNotAllowedOrTaken": "Die E-Mail konnte nicht geändert werden — sie wird bereits von einem anderen Benutzer verwendet oder ist nicht zulässig.",
"invalidEmailFormat": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"emailChangeConfirmTitle": "E-Mail-Adresse dieses Benutzers ändern?",
"emailChangeConfirmWarning": "Dadurch wird das Passwort des Benutzers zurückgesetzt und sowohl die neue als auch die bisherige Adresse benachrichtigt. Der Benutzer muss über den an die neue Adresse gesendeten Link ein neues Passwort festlegen.",
"confirm": "Bestätigen",
"resetPassword": "Passwort zurücksetzen",
"setNewPassword": "Neues Passwort setzen",
"verifyPassword": "Passwort bestätigen",

View File

@ -286,6 +286,13 @@
"forgotPasswordLink": "Forgot password?",
"provideYourUsername": "Provide your username",
"userName": "User Name",
"userEmailChangedSetPasswordSent": "Email updated. A set-password email was sent to the new address, and the previous address was notified.",
"userEmailChangeFailedReverted": "Couldn't send the set-password email — the email change was reverted.",
"userEmailChangeNotAllowedOrTaken": "Couldn't change the email — it is already in use by another user or not permitted.",
"invalidEmailFormat": "Please enter a valid email address.",
"emailChangeConfirmTitle": "Change this user's email?",
"emailChangeConfirmWarning": "This resets the user's password and emails both the new and the previous address. The user must set a new password via the link sent to the new address.",
"confirm": "Confirm",
"resetPassword": "Reset Password",
"setNewPassword": "Set New Password",
"verifyPassword": "Verify Password",

View File

@ -538,6 +538,13 @@
"forgotPasswordLink": "Mot de passe oublié ?",
"provideYourUsername": "Entrez votre nom d'utilisateur",
"userName": "Nom d'utilisateur",
"userEmailChangedSetPasswordSent": "E-mail mis à jour. Un e-mail de définition du mot de passe a été envoyé à la nouvelle adresse, et l'ancienne adresse a été avertie.",
"userEmailChangeFailedReverted": "Impossible d'envoyer l'e-mail de définition du mot de passe — la modification a été annulée.",
"userEmailChangeNotAllowedOrTaken": "Impossible de modifier l'e-mail — il est déjà utilisé par un autre utilisateur ou n'est pas autorisé.",
"invalidEmailFormat": "Veuillez saisir une adresse e-mail valide.",
"emailChangeConfirmTitle": "Modifier l'e-mail de cet utilisateur ?",
"emailChangeConfirmWarning": "Cela réinitialise le mot de passe de l'utilisateur et avertit à la fois la nouvelle et l'ancienne adresse. L'utilisateur doit définir un nouveau mot de passe via le lien envoyé à la nouvelle adresse.",
"confirm": "Confirmer",
"resetPassword": "Réinitialiser le mot de passe",
"setNewPassword": "Définir un nouveau mot de passe",
"verifyPassword": "Vérifier le mot de passe",

View File

@ -538,6 +538,13 @@
"forgotPasswordLink": "Password dimenticata?",
"provideYourUsername": "Inserisci il tuo nome utente",
"userName": "Nome utente",
"userEmailChangedSetPasswordSent": "Email aggiornata. Un'email per impostare la password è stata inviata al nuovo indirizzo e il precedente è stato avvisato.",
"userEmailChangeFailedReverted": "Impossibile inviare l'email per impostare la password — la modifica è stata annullata.",
"userEmailChangeNotAllowedOrTaken": "Impossibile modificare l'email — è già utilizzata da un altro utente o non è consentita.",
"invalidEmailFormat": "Inserisci un indirizzo email valido.",
"emailChangeConfirmTitle": "Modificare l'email di questo utente?",
"emailChangeConfirmWarning": "Questo reimposta la password dell'utente e avvisa sia il nuovo indirizzo sia quello precedente. L'utente deve impostare una nuova password tramite il link inviato al nuovo indirizzo.",
"confirm": "Conferma",
"resetPassword": "Reimposta password",
"setNewPassword": "Imposta nuova password",
"verifyPassword": "Verifica password",