diff --git a/client/src/components/Authorization/UpdateAccount.jsx b/client/src/components/Authorization/UpdateAccount.jsx index 7bd5ca0d..f5462dd1 100644 --- a/client/src/components/Authorization/UpdateAccount.jsx +++ b/client/src/components/Authorization/UpdateAccount.jsx @@ -37,6 +37,7 @@ const UpdateAccount = props => { }; const [errorMsg, setErrorMsg] = useState(""); + const [successMsg, setSuccessMsg] = useState(""); const [submitted, setSubmitted] = useState(false); const updateAccountSchema = Yup.object().shape({ @@ -51,6 +52,9 @@ const UpdateAccount = props => { { firstName, lastName, email }, { setSubmitting } ) => { + setErrorMsg(""); + setSuccessMsg(""); + try { const response = await accountService.updateAccount( firstName, @@ -58,13 +62,18 @@ const UpdateAccount = props => { email ); - if (response.isSuccess) { + if (response.code === "ACCOUNT_EMAIL_UPDATE_SUCCESS") { + userContext.updateAccount(response.user); setSubmitted(true); - userContext.updateAccount({}); return; } switch (response.code) { + case "ACCOUNT_UPDATE_SUCCESS": + setSuccessMsg(response.message); + userContext.updateAccount(response.user); + break; + case "ERR_INVALID_ADMIN_DOMAIN": setErrorMsg(response.message); break; @@ -181,7 +190,7 @@ const UpdateAccount = props => {

- {errorMsg} + {successMsg || errorMsg}
)} diff --git a/server/app/services/account.service.js b/server/app/services/account.service.js index f994f4bc..82495fb1 100644 --- a/server/app/services/account.service.js +++ b/server/app/services/account.service.js @@ -130,6 +130,38 @@ const handleVerifyUpdateConfirmation = async (email, token) => { } }; +const handleEmailAccountUpdate = async (model, user) => { + const token = crypto.randomUUID(); + + const tokenRequest = pool.request(); + tokenRequest.input("token", mssql.NVarChar, token); + tokenRequest.input("email", mssql.NVarChar, model.email); + await tokenRequest.execute("SecurityToken_Insert"); + + const emailChangeRequest = pool.request(); + emailChangeRequest.input("userId", mssql.Int, model.id); + emailChangeRequest.input("requestedEmail", mssql.NVarChar, model.email); + emailChangeRequest.input("activeEmail", mssql.NVarChar, user.email); + await emailChangeRequest.execute("LoginEmailChangeHistory_Insert"); + + await handleVerifyUpdateConfirmation(model.email, token); + + return { + isSuccess: true, + code: "ACCOUNT_EMAIL_UPDATE_SUCCESS", + message: "Account updates successful.", + user: { + id: user.id, + firstName: model.firstName, + lastName: model.lastName, + email: user.email, // remains current email until verified + isAdmin: user.isAdmin, + emailConfirmed: user.emailConfirmed, // remains confirmed to allow authorized login on current email + isSecurityAdmin: user.isSecurityAdmin + } + }; +}; + const updateAccount = async model => { try { const user = await selectById(model.id); @@ -143,20 +175,33 @@ const updateAccount = async model => { await validateUniqueEmail(model.email, model.id); await poolConnect; + + // Update names const request = pool.request(); request.input("id", mssql.Int, model.id); request.input("FirstName", mssql.NVarChar, model.firstName); request.input("LastName", mssql.NVarChar, model.lastName); - request.input("Email", mssql.NVarChar, model.email); await request.execute("Login_Update"); - const token = crypto.randomUUID(); - await handleVerifyUpdateConfirmation(model.email, token); + // Email change flow + if (user.email !== model.email) { + return await handleEmailAccountUpdate(model, user); + } + // Name-only update flow return { isSuccess: true, code: "ACCOUNT_UPDATE_SUCCESS", - message: "Account updates succeeded." + message: "Account updates successful.", + user: { + id: user.id, + firstName: model.firstName, + lastName: model.lastName, + email: user.email, + isAdmin: user.isAdmin, + emailConfirmed: user.emailConfirmed, + isSecurityAdmin: user.isSecurityAdmin + } }; } catch (err) { return { @@ -171,24 +216,33 @@ const updateAccount = async model => { const resendConfirmationEmail = async email => { try { await poolConnect; - const request = pool.request(); - request.input("email", mssql.NVarChar, email); - const selectByEmailResponse = await request.execute("Login_SelectByEmail"); + const emailRequest = pool.request(); + emailRequest.input("email", mssql.NVarChar(100), email); + const emailResponse = await emailRequest.execute( + "Login_SelectByEmailAndPendingEmail" + ); + const userRecord = emailResponse.recordset[0]; - let result = { + if (!userRecord) { + return { + isSuccess: false, + code: "REG_ACCOUNT_NOT_FOUND", + message: `Account not found for email: ${email}` + }; + } + + const result = { isSuccess: true, code: "REG_SUCCESS", - newId: selectByEmailResponse.recordset[0].id, + newId: userRecord.id, message: "Account found." }; - result = await requestRegistrationConfirmation(email, result); - return result; + + return await requestRegistrationConfirmation(email, result); } catch (err) { - // Assume any error is an email that does not correspond to - // an account. return { isSuccess: false, - code: "REG_ACCOUNT_NOT_FOUND", + code: "RESEND_FAILED", message: `Resending confirmation email to ${email} failed due to: ${err.message}` }; } @@ -226,8 +280,7 @@ const confirmRegistration = async token => { try { await poolConnect; const request = pool.request(); - - request.input("token", mssql.NVarChar, token); + request.input("token", mssql.NVarChar(200), token); const sqlResult = await request.execute("SecurityToken_SelectByToken"); const resultSet = sqlResult.recordset; @@ -241,7 +294,8 @@ const confirmRegistration = async token => { "Email confirmation failed. Invalid security token. Re-send confirmation email." }; } else if ( - (now.getTime() - resultSet[0].dateCreated.getTime()) / (60 * 60 * 1000) >= + (now.getTime() - new Date(resultSet[0].dateCreated).getTime()) / + (60 * 60 * 1000) >= 24 ) { return { @@ -252,20 +306,47 @@ const confirmRegistration = async token => { }; } - // If we get this far, we can update the login.email_confirmed flag const email = resultSet[0].email; - const updateRequest = await pool.request(); - updateRequest.input("email", mssql.NVarChar, email); - await updateRequest.execute("Login_ConfirmEmail"); + + // Check for an active pending change request + const historyRequest = pool.request(); + historyRequest.input("RequestedEmail", mssql.NVarChar(100), email); + + const historyResult = await historyRequest.execute( + "LoginEmailChangeHistory_SelectByRecentPendingEmail" + ); + const pendingEmailChange = historyResult.recordset[0]; + + const confirmRequest = pool.request(); + confirmRequest.input("email", mssql.NVarChar(100), email); + + if (pendingEmailChange) { + const userId = pendingEmailChange.userId; + await validateUniqueEmail(email, userId); + await confirmRequest.execute("Login_ConfirmUpdateEmail"); + + return { + isSuccess: true, + code: "REG_CONFIRM_SUCCESS", + message: "Email change confirmed successfully.", + email + }; + } + // First-time registration + await confirmRequest.execute("Login_ConfirmEmail"); return { isSuccess: true, code: "REG_CONFIRM_SUCCESS", - message: "Email confirmed.", + message: "Email confirmed successfully.", email }; } catch (err) { - return { message: err.message }; + return { + isSuccess: false, + code: "CONFIRM_FAILED", + message: err.message + }; } }; diff --git a/server/db/migration/V20260909.1208__create_login_email_update.sql b/server/db/migration/V20260909.1208__create_login_email_update.sql new file mode 100644 index 00000000..315cbe10 --- /dev/null +++ b/server/db/migration/V20260909.1208__create_login_email_update.sql @@ -0,0 +1,281 @@ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +CREATE OR ALTER PROC [dbo].[Login_Update] + @id int, + @firstName nvarchar(50), + @lastName nvarchar(50) + +AS +BEGIN + + UPDATE Login + SET + firstName = @firstName, + lastName = @lastName + + WHERE + id = @id; + +END +GO + + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +CREATE TABLE [dbo].[LoginEmailChangeHistory] +( + [id] [int] IDENTITY(1,1) NOT NULL, + [userId] [int] NOT NULL INDEX IX_LoginEmailChangeHistory_UserId NONCLUSTERED, + [requestedEmail] [nvarchar](100) NOT NULL INDEX IX_LoginEmailChangeHistory_RequestedEmail NONCLUSTERED, + [activeEmail] [nvarchar](100) NULL, + [lastActiveEmail] [nvarchar](100) NULL, + [dateRequested] [datetime2](7) NOT NULL DEFAULT (SYSUTCDATETIME()), + [dateChanged] [datetime2](7) NULL, + PRIMARY KEY ([id]), + FOREIGN KEY ([userId]) REFERENCES [dbo].[Login] ([id]) +); +GO + + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +CREATE OR ALTER PROCEDURE [dbo].[LoginEmailChangeHistory_Insert] + @userId INT, + @requestedEmail NVARCHAR(100), + @activeEmail NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Invalidate any prior pending change requests for this user + DELETE FROM [dbo].[LoginEmailChangeHistory] + WHERE [userId] = @userId + AND [dateChanged] IS NULL; + + + INSERT INTO [dbo].[LoginEmailChangeHistory] + ( + [userId], + [requestedEmail], + [activeEmail], + [lastActiveEmail], + [dateRequested], + [dateChanged] + ) + VALUES + ( + @userId, + @requestedEmail, + @activeEmail, -- Active at time of request + NULL, -- NULL until updated + SYSUTCDATETIME(), + NULL + ); + + + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + + THROW; + END CATCH; +END; +GO + + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +CREATE OR ALTER PROCEDURE [dbo].[Login_ConfirmUpdateEmail] + @email NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRY + BEGIN TRANSACTION; + + DECLARE @userId INT; + + -- Find the user ID for the most recent pending change request + SELECT TOP (1) + @userId = [userId] + FROM [dbo].[LoginEmailChangeHistory] + WHERE [requestedEmail] = @email + AND [dateChanged] IS NULL + ORDER BY [dateRequested] DESC; + + IF @userId IS NULL + BEGIN + ;THROW 50001, 'No pending email update request found for this email.', 1; + END; + + -- Update Login table with verified email + UPDATE [dbo].[Login] + SET [email] = @email + WHERE [id] = @userId; + + -- Mark history record as completed + UPDATE [dbo].[LoginEmailChangeHistory] + SET + [lastActiveEmail] = [activeEmail], + [activeEmail] = @email, + [dateChanged] = SYSUTCDATETIME() + WHERE [requestedEmail] = @email + AND [userId] = @userId + AND [dateChanged] IS NULL; + + COMMIT TRANSACTION; + + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + + THROW; + END CATCH; +END; +GO + + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +CREATE OR ALTER PROCEDURE [dbo].[LoginEmailChangeHistory_SelectByRecentPendingEmail] + @requestedEmail NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + SELECT TOP (1) + [id], + [userId], + [requestedEmail], + [activeEmail], + [lastActiveEmail], + [dateRequested], + [dateChanged] + FROM [dbo].[LoginEmailChangeHistory] + WHERE [requestedEmail] = @requestedEmail + ORDER BY [dateRequested] DESC; +END; +GO + + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +CREATE OR ALTER PROCEDURE [dbo].[Login_SelectByEmailAndPendingEmail] + @email NVARCHAR(100) +AS +BEGIN + SET NOCOUNT ON; + + -- Check active account in Login table + IF EXISTS (SELECT 1 FROM [dbo].[Login] WHERE [email] = @email) + BEGIN + SELECT + [id], + [firstName], + [lastName], + [email], + [dateCreated], + [emailConfirmed], + [isAdmin], + [passwordHash], + [isSecurityAdmin], + [archivedAt], + [isDro] + FROM [dbo].[Login] + WHERE [email] = @email; + + RETURN; + END; + + -- Check if pending change request in LoginEmailChangeHistory + DECLARE @userId INT; + + SELECT TOP (1) + @userId = [userId] + FROM [dbo].[LoginEmailChangeHistory] + WHERE [requestedEmail] = @email + AND [dateChanged] IS NULL + ORDER BY [dateRequested] DESC; + + -- If found as a pending request, return the user record using userId + IF @userId IS NOT NULL + BEGIN + SELECT + [id], + [firstName], + [lastName], + [email], + [dateCreated], + [emailConfirmed], + [isAdmin], + [passwordHash], + [isSecurityAdmin], + [archivedAt], + [isDro] + FROM [dbo].[Login] + WHERE [id] = @userId; + END; +END; +GO + + +CREATE OR ALTER PROCEDURE [dbo].[DeleteUserAndProjects] + @id INT +AS +BEGIN + SET NOCOUNT ON; + + IF EXISTS ( + SELECT 1 + FROM [dbo].[Project] + WHERE [loginId] = @id + AND [dateSubmitted] IS NOT NULL + AND [dateTrashed] IS NULL + ) + BEGIN + RAISERROR('Cannot delete account with submissions. Account has projects that have been submitted.', 16, 1); + RETURN; + END; + + BEGIN TRY + BEGIN TRANSACTION; + + DELETE FROM [dbo].[Project] WHERE [loginId] = @id; + DELETE FROM [dbo].[LoginHistory] WHERE [loginId] = @id; + DELETE FROM [dbo].[LoginEmailChangeHistory] WHERE [userId] = @id; + + -- Delete the parent user record + DELETE FROM [dbo].[Login] WHERE [id] = @id; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH; +END; +GO \ No newline at end of file