Improve invitation messages and flow

Closes #29945

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-28 18:00:41 -03:00 committed by Alexander Schwartz
parent 2c521bd64d
commit 320f8eb1b4
14 changed files with 141 additions and 34 deletions

View file

@ -1,13 +1,13 @@
/* /*
* Copyright 2024 Red Hat, Inc. and/or its affiliates * Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags. * and other contributors as indicated by the @author tags.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -17,6 +17,7 @@
package org.keycloak.authentication.actiontoken.inviteorg; package org.keycloak.authentication.actiontoken.inviteorg;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import org.keycloak.TokenVerifier.Predicate; import org.keycloak.TokenVerifier.Predicate;
@ -43,7 +44,7 @@ import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.List; import java.net.URI;
import java.util.Objects; import java.util.Objects;
/** /**
@ -112,9 +113,9 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
return session.getProvider(LoginFormsProvider.class) return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession) .setAuthenticationSession(authSession)
.setSuccess(Messages.CONFIRM_EXECUTION_OF_ACTIONS) .setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName())
.setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE)
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri)
.setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, List.of(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP))
.setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName()) .setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName())
.createInfoPage(); .createInfoPage();
} }
@ -135,6 +136,17 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
tokenContext.setEvent(event.clone().removeDetail(Details.EMAIL).event(EventType.LOGIN)); tokenContext.setEvent(event.clone().removeDetail(Details.EMAIL).event(EventType.LOGIN));
String nextAction = AuthenticationManager.nextRequiredAction(session, authSession, tokenContext.getRequest(), event); String nextAction = AuthenticationManager.nextRequiredAction(session, authSession, tokenContext.getRequest(), event);
if (nextAction == null) {
// do not show account updated page
authSession.removeAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS);
if (redirectUri != null) {
// always redirect to the expected URI if provided
return Response.status(Status.FOUND).location(URI.create(redirectUri)).build();
}
}
return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction); return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction);
} }
} }

View file

@ -17,17 +17,27 @@
package org.keycloak.authentication.forms; package org.keycloak.authentication.forms;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.authentication.FormAuthenticator; import org.keycloak.authentication.FormAuthenticator;
import org.keycloak.authentication.FormAuthenticatorFactory; import org.keycloak.authentication.FormAuthenticatorFactory;
import org.keycloak.authentication.FormContext; import org.keycloak.authentication.FormContext;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OrganizationModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.keycloak.services.messages.Messages;
import java.util.List; import java.util.List;
/** /**
@ -47,6 +57,27 @@ public class RegistrationPage implements FormAuthenticator, FormAuthenticatorFac
@Override @Override
public Response render(FormContext context, LoginFormsProvider form) { public Response render(FormContext context, LoginFormsProvider form) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
try {
InviteOrgActionToken token = Organizations.parseInvitationToken(context.getHttpRequest());
if (token != null) {
KeycloakSession session = context.getSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = provider.getById(token.getOrgId());
if (organization == null || !organization.isEnabled()) {
return form.setError(Messages.EXPIRED_ACTION).createErrorPage(Status.BAD_REQUEST);
}
form.setAttribute("messageHeader", Messages.REGISTER_ORGANIZATION_MEMBER);
form.setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName());
}
} catch (VerificationException e) {
return form.setError(Messages.EXPIRED_ACTION).createErrorPage(Status.BAD_REQUEST);
}
}
return form.createRegistration(); return form.createRegistration();
} }

View file

@ -19,7 +19,6 @@ package org.keycloak.authentication.forms;
import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedHashMap;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException; import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.FormAction; import org.keycloak.authentication.FormAction;
@ -37,7 +36,6 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
@ -46,6 +44,7 @@ import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage; import org.keycloak.models.utils.FormMessage;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
@ -290,29 +289,24 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap<String, String> formData, String email) { private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap<String, String> formData, String email) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
MultivaluedMap<String, String> queryParameters = context.getHttpRequest().getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);
if (tokenFromQuery == null) {
return true;
}
Consumer<List<FormMessage>> error = messages -> { Consumer<List<FormMessage>> error = messages -> {
context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery);
context.error(Errors.INVALID_TOKEN); context.error(Errors.INVALID_TOKEN);
context.validationError(formData, messages); context.validationError(formData, messages);
}; };
TokenVerifier<InviteOrgActionToken> tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class);
InviteOrgActionToken token; InviteOrgActionToken token;
try { try {
token = tokenVerifier.getToken(); token = Organizations.parseInvitationToken(context.getHttpRequest());
} catch (VerificationException e) { } catch (VerificationException e) {
error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token"))); error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token")));
return false; return false;
} }
if (token == null) {
return true;
}
KeycloakSession session = context.getSession(); KeycloakSession session = context.getSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class); OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = provider.getById(token.getOrgId()); OrganizationModel organization = provider.getById(token.getOrgId());

View file

@ -83,8 +83,13 @@ public class OrganizationInvitationResource {
return sendInvitation(user); return sendInvitation(user);
} }
if (!realm.isRegistrationAllowed()) {
throw ErrorResponse.error("Realm does not allow self-registration", Status.BAD_REQUEST);
}
user = new InMemoryUserAdapter(session, realm, null); user = new InMemoryUserAdapter(session, realm, null);
user.setEmail(email); user.setEmail(email);
if (firstName != null && lastName != null) { if (firstName != null && lastName != null) {
user.setFirstName(firstName); user.setFirstName(firstName);
user.setLastName(lastName); user.setLastName(lastName);

View file

@ -19,14 +19,21 @@ package org.keycloak.organization.utils;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature; import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
@ -180,4 +187,15 @@ public class Organizations {
public static OrganizationDomainModel toModel(OrganizationDomainRepresentation domainRepresentation) { public static OrganizationDomainModel toModel(OrganizationDomainRepresentation domainRepresentation) {
return new OrganizationDomainModel(domainRepresentation.getName(), domainRepresentation.isVerified()); return new OrganizationDomainModel(domainRepresentation.getName(), domainRepresentation.isVerified());
} }
public static InviteOrgActionToken parseInvitationToken(HttpRequest request) throws VerificationException {
MultivaluedMap<String, String> queryParameters = request.getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);
if (tokenFromQuery == null) {
return null;
}
return TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class).getToken();
}
} }

View file

@ -192,7 +192,7 @@ public class Messages {
public static final String STALE_INVITE_ORG_LINK = "staleInviteOrgLink"; public static final String STALE_INVITE_ORG_LINK = "staleInviteOrgLink";
public static final String IDENTITY_PROVIDER_UNEXPECTED_ERROR = "identityProviderUnexpectedErrorMessage"; public static final String IDENTITY_PROVIDER_UNEXPECTED_ERROR = "identityProviderUnexpectedErrorMessage";
public static final String IDENTITY_PROVIDER_UNMATCHED_ESSENTIAL_CLAIM_ERROR = "federatedIdentityUnmatchedEssentialClaimMessage"; public static final String IDENTITY_PROVIDER_UNMATCHED_ESSENTIAL_CLAIM_ERROR = "federatedIdentityUnmatchedEssentialClaimMessage";
public static final String IDENTITY_PROVIDER_MISSING_STATE_ERROR = "identityProviderMissingStateMessage"; public static final String IDENTITY_PROVIDER_MISSING_STATE_ERROR = "identityProviderMissingStateMessage";
@ -321,4 +321,6 @@ public class Messages {
public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage"; public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage";
public static final String CONFIRM_ORGANIZATION_MEMBERSHIP = "organization.confirm-membership"; public static final String CONFIRM_ORGANIZATION_MEMBERSHIP = "organization.confirm-membership";
public static final String CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE = "organization.confirm-membership.title";
public static final String REGISTER_ORGANIZATION_MEMBER = "organization.member.register.title";
} }

View file

@ -58,6 +58,10 @@ public abstract class AbstractPage {
abstract public boolean isCurrent(); abstract public boolean isCurrent();
public boolean isCurrent(String expectedTitle) {
return PageUtils.getPageTitle(driver).equals(expectedTitle);
}
abstract public void open() throws Exception; abstract public void open() throws Exception;
public WebDriver getDriver() { public WebDriver getDriver() {

View file

@ -26,6 +26,7 @@ import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.testsuite.auth.page.AccountFields; import org.keycloak.testsuite.auth.page.AccountFields;
import org.keycloak.testsuite.auth.page.PasswordFields; import org.keycloak.testsuite.auth.page.PasswordFields;
import org.keycloak.testsuite.util.DroneUtils;
import org.keycloak.testsuite.util.UIUtils; import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NoSuchElementException;
@ -245,7 +246,7 @@ public class RegisterPage extends AbstractPage {
public boolean isCurrent() { public boolean isCurrent() {
return PageUtils.getPageTitle(driver).equals("Register"); return isCurrent("Register");
} }
public AccountFields.AccountErrors getInputAccountErrors(){ public AccountFields.AccountErrors getInputAccountErrors(){
@ -267,4 +268,9 @@ public class RegisterPage extends AbstractPage {
assertCurrent(); assertCurrent();
} }
public void assertCurrent(String orgName) {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")",
isCurrent("Create an account to join the " + orgName + " organization"));
}
} }

View file

@ -19,6 +19,7 @@ package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import java.io.IOException; import java.io.IOException;
@ -29,6 +30,7 @@ import java.util.concurrent.TimeUnit;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule; import org.junit.Rule;
@ -37,6 +39,7 @@ import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.common.Profile.Feature; import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.UriUtils; import org.keycloak.common.util.UriUtils;
import org.keycloak.cookie.CookieType; import org.keycloak.cookie.CookieType;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.Assert;
@ -135,7 +138,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken); Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim()); driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent(); registerPage.assertCurrent(organizationName);
registerPage.register("firstName", "lastName", user.getEmail(), registerPage.register("firstName", "lastName", user.getEmail(),
user.getUsername(), "password", "password", null, false, null); user.getUsername(), "password", "password", null, false, null);
List<UserRepresentation> users = testRealm().users().searchByEmail(user.getEmail(), true); List<UserRepresentation> users = testRealm().users().searchByEmail(user.getEmail(), true);
@ -149,6 +152,27 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName())); Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName()));
} }
@Test
public void testFailRegistrationNotEnabledWhenInvitingNewUser() throws IOException, MessagingException {
UserRepresentation user = UserBuilder.create()
.username("invitedUser")
.email("inviteduser@email")
.enabled(true)
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
RealmRepresentation realm = testRealm().toRepresentation();
realm.setRegistrationAllowed(false);
testRealm().update(realm);
try (Response response = organization.members().inviteUser(user.getEmail(), null, null)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
assertEquals("Realm does not allow self-registration", response.readEntity(ErrorRepresentation.class).getErrorMessage());
} finally {
realm.setRegistrationAllowed(true);
testRealm().update(realm);
}
}
@Test @Test
public void testEmailDoesNotChangeOnRegistration() throws IOException { public void testEmailDoesNotChangeOnRegistration() throws IOException {
UserRepresentation user = UserBuilder.create() UserRepresentation user = UserBuilder.create()
@ -168,8 +192,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken); Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim()); driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent(); registerPage.assertCurrent(organizationName);
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
registerPage.register("firstName", "lastName", "invalid@email.com", registerPage.register("firstName", "lastName", "invalid@email.com",
user.getUsername(), "password", "password", null, false, null); user.getUsername(), "password", "password", null, false, null);
Assert.assertTrue(driver.getPageSource().contains("Email does not match the invitation")); Assert.assertTrue(driver.getPageSource().contains("Email does not match the invitation"));
@ -198,7 +221,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken); Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim()); driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent(); registerPage.assertCurrent(organizationName);
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS); driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
registerPage.register("firstName", "lastName", "invalid@email.com", registerPage.register("firstName", "lastName", "invalid@email.com",
user.getUsername(), "password", "password", null, false, null); user.getUsername(), "password", "password", null, false, null);
@ -216,17 +239,19 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertEquals("Invitation to join the " + organizationName + " organization", message.getSubject()); Assert.assertEquals("Invitation to join the " + organizationName + " organization", message.getSubject());
EmailBody body = MailUtils.getBody(message); EmailBody body = MailUtils.getBody(message);
if (user.getFirstName() != null && user.getLastName() != null) { if (user.getFirstName() != null && user.getLastName() != null) {
assertThat(body.getText(), Matchers.containsString(user.getFirstName() + " " + user.getLastName())); assertThat(body.getText(), Matchers.containsString("Hi, " + user.getFirstName() + " " + user.getLastName() + "."));
} }
String link = MailUtils.getLink(body.getHtml()); String link = MailUtils.getLink(body.getHtml());
driver.navigate().to(link.trim()); driver.navigate().to(link.trim());
// not yet a member // not yet a member
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
// confirm the intent of membership // confirm the intent of membership
assertThat(infoPage.getInfo(), containsString("You are about to join organization " + organizationName)); assertThat(driver.getPageSource(), containsString("You are about to join organization " + organizationName));
assertThat(infoPage.getInfo(), containsString("By clicking on the link below, you will become a member of the " + organizationName + " organization:"));
infoPage.clickToContinue(); infoPage.clickToContinue();
assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); // redirect to the account console and eventually force the user to authenticate if not already
assertThat(driver.getTitle(), containsString("Account Management"));
// now a member // now a member
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation()); Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
} }
} }

View file

@ -4,8 +4,8 @@ emailVerificationBodyHtml=<p>Someone has created a {2} account with this email a
orgInviteSubject=Invitation to join the {0} organization orgInviteSubject=Invitation to join the {0} organization
orgInviteBody=You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message. orgInviteBody=You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyHtml=<p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p> orgInviteBodyHtml=<p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
orgInviteBodyPersonalized="{5}" "{6}"\n\n You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message. orgInviteBodyPersonalized=Hi, "{5}" "{6}".\n\n You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyPersonalizedHtml=<p>{5} {6}</p><p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p> orgInviteBodyPersonalizedHtml=<p>Hi, {5} {6}.</p><p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
emailUpdateConfirmationSubject=Verify new email emailUpdateConfirmationSubject=Verify new email
emailUpdateConfirmationBody=To update your {2} account with email address {1}, click the link below\n\n{0}\n\nThis link will expire within {3}.\n\nIf you don''t want to proceed with this modification, just ignore this message. emailUpdateConfirmationBody=To update your {2} account with email address {1}, click the link below\n\n{0}\n\nThis link will expire within {3}.\n\nIf you don''t want to proceed with this modification, just ignore this message.
emailUpdateConfirmationBodyHtml=<p>To update your {2} account with email address {1}, click the link below</p><p><a href="{0}">{0}</a></p><p>This link will expire within {3}.</p><p>If you don''t want to proceed with this modification, just ignore this message.</p> emailUpdateConfirmationBodyHtml=<p>To update your {2} account with email address {1}, click the link below</p><p><a href="{0}">{0}</a></p><p>This link will expire within {3}.</p><p>If you don''t want to proceed with this modification, just ignore this message.</p>

View file

@ -2,7 +2,7 @@
<@layout.registrationLayout displayMessage=false; section> <@layout.registrationLayout displayMessage=false; section>
<#if section = "header"> <#if section = "header">
<#if messageHeader??> <#if messageHeader??>
${messageHeader} ${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else> <#else>
${message.summary} ${message.summary}
</#if> </#if>

View file

@ -518,4 +518,6 @@ doLogout=Logout
readOnlyUsernameMessage=You can''t update your username as it is read-only. readOnlyUsernameMessage=You can''t update your username as it is read-only.
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} value(s). error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} value(s).
requiredAction.organization.confirm-membership=You are about to join organization ${kc.org.name} organization.confirm-membership.title=You are about to join organization ${kc.org.name}
organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization:
organization.member.register.title=Create an account to join the ${kc.org.name} organization

View file

@ -3,7 +3,11 @@
<#import "register-commons.ftl" as registerCommons> <#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section> <@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header"> <#if section = "header">
${msg("registerTitle")} <#if messageHeader??>
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${msg("registerTitle")}
</#if>
<#elseif section = "form"> <#elseif section = "form">
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post"> <form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post">

View file

@ -3,7 +3,11 @@
<#import "register-commons.ftl" as registerCommons> <#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section> <@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header"> <#if section = "header">
${msg("registerTitle")} <#if messageHeader??>
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${msg("registerTitle")}
</#if>
<#elseif section = "form"> <#elseif section = "form">
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post"> <form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post">