mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
Make pending email verification attribute removable by admin
Closes #43351 Signed-off-by: Martin Kanis <mkanis@redhat.com> (cherry picked from commit 986fdd7341a0f42a59f5eec1bd6c3d5a715f2893)
This commit is contained in:
parent
7c50d94f14
commit
a321c2c91f
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -68,6 +68,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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user