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