Make pending email verification attribute removable by admin

Closes #43351

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2025-10-10 12:01:04 +02:00 committed by Pedro Igor
parent b858e696bc
commit 986fdd7341
6 changed files with 94 additions and 3 deletions

View File

@ -330,6 +330,7 @@ policiesConfigType=Configure via\:
exportWarningTitle=Export with caution
emailVerifiedHelp=Has the user's email been verified?
emailPendingVerification=Email pending verification
emailPendingVerificationHelp=This field can only be cleared. Clearing it will invalidate the verification link.
duplicateFlow=Duplicate flow
addExecution=Add execution
noSearchResultsInstructions=Click on the search bar above to search again

View File

@ -8,6 +8,7 @@ import {
SwitchControl,
TextControl,
UserProfileFields,
beerify,
} from "@keycloak/keycloak-ui-shared";
import {
AlertVariant,
@ -21,7 +22,7 @@ import {
TextInput,
} from "@patternfly/react-core";
import { TFunction } from "i18next";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Controller, FormProvider, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
@ -150,10 +151,45 @@ export const UserForm = ({
?.map((a) => a.readOnly)
.reduce((p, c) => p && c, true);
const validateAndSave = useCallback(
(formData: UserFormFields) => {
const originalEmailPendingValue =
user?.attributes?.["kc.email.pending"]?.[0];
if (
formData.attributes &&
!Array.isArray(formData.attributes) &&
originalEmailPendingValue
) {
const emailPendingValue = (
formData.attributes as Record<string, string | string[]>
)[beerify("kc.email.pending")];
const currentValue = Array.isArray(emailPendingValue)
? emailPendingValue[0]
: emailPendingValue;
if (currentValue === "") {
// Field was cleared - keep as empty string to clear the value
(formData.attributes as Record<string, string | string[]>)[
beerify("kc.email.pending")
] = "";
} else if (currentValue && currentValue !== originalEmailPendingValue) {
// Field was modified (not cleared) - revert to original value
(formData.attributes as Record<string, string | string[]>)[
beerify("kc.email.pending")
] = originalEmailPendingValue;
}
}
save(formData);
},
[save, user],
);
return (
<FormAccess
isHorizontal
onSubmit={handleSubmit(save)}
onSubmit={handleSubmit(validateAndSave)}
role="query-users"
fineGrainedAccess={user?.access?.manage}
className="pf-v5-u-mt-lg"
@ -242,7 +278,20 @@ export const UserForm = ({
/>
<UserProfileFields
form={form}
userProfileMetadata={userProfileMetadata}
userProfileMetadata={{
...userProfileMetadata,
attributes: userProfileMetadata.attributes?.map((attr) =>
attr.name === "kc.email.pending"
? {
...attr,
annotations: {
...attr.annotations,
inputHelperTextBefore: "emailPendingVerificationHelp",
},
}
: attr,
),
}}
hideReadOnly={!user}
supportedLocales={realm.supportedLocales || []}
currentLocale={whoAmI.locale}

View File

@ -66,6 +66,13 @@ public class UpdateEmailActionTokenHandler extends AbstractActionTokenHandler<Up
String newEmail = token.getNewEmail();
// Check if EMAIL_PENDING attribute exists and matches the token's new email
// This prevents the token from being used if an admin has removed the pending verification
String pendingEmail = user.getFirstAttribute(UserModel.EMAIL_PENDING);
if (pendingEmail == null || !Objects.equals(pendingEmail, newEmail)) {
return forms.setError(Messages.EMAIL_VERIFICATION_CANCELLED).createErrorPage(Response.Status.BAD_REQUEST);
}
UserProfile emailUpdateValidationResult;
try {
emailUpdateValidationResult = UpdateEmail.validateEmailUpdate(session, user, newEmail);

View File

@ -203,6 +203,8 @@ public class Messages {
public static final String STALE_VERIFY_EMAIL_LINK = "staleEmailVerificationLink";
public static final String EMAIL_VERIFICATION_CANCELLED = "emailVerificationCancelled";
public static final String STALE_INVITE_ORG_LINK = "staleInviteOrgLink";
public static final String IDENTITY_PROVIDER_UNEXPECTED_ERROR = "identityProviderUnexpectedErrorMessage";

View File

@ -622,4 +622,35 @@ public class RequiredActionUpdateEmailTestWithVerificationTest extends AbstractR
infoPage.clickBackToApplicationLink();
appPage.assertCurrent();
}
@Test
public void testEmailVerificationCancelledByAdmin() throws Exception {
configureRequiredActionsToUser("test-user@localhost", UserModel.RequiredAction.UPDATE_EMAIL.name());
loginPage.open();
loginPage.login("test-user@localhost", "password");
updateEmailPage.assertCurrent();
updateEmailPage.changeEmail("new@localhost");
events.expect(EventType.SEND_VERIFY_EMAIL).detail(Details.EMAIL, "new@localhost").assertEvent();
// Verify EMAIL_PENDING attribute is set
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");
Map<String, List<String>> attributes = user.getAttributes();
assertEquals("EMAIL_PENDING should contain new email", "new@localhost", attributes.get(UserModel.EMAIL_PENDING).get(0));
assertTrue("User should have UPDATE_EMAIL required action", user.getRequiredActions().contains(UserModel.RequiredAction.UPDATE_EMAIL.name()));
String confirmationLink = fetchEmailConfirmationLink("new@localhost");
assertNotNull("Should have received verification email", confirmationLink);
// Admin sets EMAIL_PENDING to empty string (simulating admin UI removal)
user.singleAttribute(UserModel.EMAIL_PENDING, "");
testRealm().users().get(user.getId()).update(user);
driver.navigate().to(confirmationLink);
errorPage.assertCurrent();
assertEquals("This email verification has been cancelled by an administrator.", errorPage.getError());
}
}

View File

@ -382,6 +382,7 @@ emailVerifiedMessage=Your email address has been verified.
emailVerifiedAlreadyMessageHeader=Email address verified
emailVerifiedAlreadyMessage=Your email address has been verified already.
staleEmailVerificationLink=The link you clicked is an old stale link and is no longer valid. Maybe you have already verified your email.
emailVerificationCancelled=This email verification has been cancelled by an administrator.
identityProviderAlreadyLinkedMessage=Federated identity returned by {0} is already linked to another user.
confirmAccountLinking=Confirm linking the account {0} of identity provider {1} with your account.
confirmAccountLinkingBody=If you link the account, you will also be able to login using account {0} of the identity provider {1}. Do not proceed if you did not initiate this process or you do not want to link the account.