mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
Improvements to the notify step
Closes #44708 Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
@@ -77,6 +77,11 @@ final class DefaultWorkflowExecutionContext implements WorkflowExecutionContext
|
||||
return event;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorkflowStep getNextStep() {
|
||||
return workflow.getSteps(currentStep.getId()).skip(1).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
String getExecutionId() {
|
||||
return this.executionId;
|
||||
}
|
||||
|
||||
@@ -216,10 +216,6 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
|
||||
public void close() {
|
||||
}
|
||||
|
||||
WorkflowStepProvider getStepProvider(WorkflowStep step) {
|
||||
return getStepProviderFactory(step).create(session, realm.getComponent(step.getId()));
|
||||
}
|
||||
|
||||
private ComponentModel getWorkflowComponent(String id) {
|
||||
ComponentModel component = realm.getComponent(id);
|
||||
|
||||
@@ -238,17 +234,6 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
|
||||
return (WorkflowProvider) factory.create(session, realm.getComponent(workflow.getId()));
|
||||
}
|
||||
|
||||
private WorkflowStepProviderFactory<WorkflowStepProvider> getStepProviderFactory(WorkflowStep step) {
|
||||
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
|
||||
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId());
|
||||
|
||||
if (factory == null) {
|
||||
throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId());
|
||||
}
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
private void processEvent(Stream<Workflow> workflows, WorkflowEvent event) {
|
||||
Map<String, ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByResource(event.getResourceId())
|
||||
.collect(Collectors.toMap(ScheduledStep::workflowId, Function.identity()));
|
||||
|
||||
@@ -7,6 +7,8 @@ import org.keycloak.models.KeycloakSession;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import static org.keycloak.models.workflow.Workflows.getStepProvider;
|
||||
|
||||
class RunWorkflowTask extends WorkflowTransactionalTask {
|
||||
|
||||
private static final Logger log = Logger.getLogger(RunWorkflowTask.class);
|
||||
@@ -28,7 +30,7 @@ class RunWorkflowTask extends WorkflowTransactionalTask {
|
||||
|
||||
if (currentStep != null) {
|
||||
// we are resuming from a scheduled step - run it and then continue with the rest of the workflow
|
||||
runWorkflowStep(provider, context);
|
||||
runWorkflowStep(session, provider, context);
|
||||
}
|
||||
|
||||
List<WorkflowStep> stepsToRun = workflow.getSteps()
|
||||
@@ -38,14 +40,14 @@ class RunWorkflowTask extends WorkflowTransactionalTask {
|
||||
for (WorkflowStep step : stepsToRun) {
|
||||
if (DurationConverter.isPositiveDuration(step.getAfter())) {
|
||||
// If a step has a time defined, schedule it and stop processing the other steps of workflow
|
||||
log.debugf("Scheduling step %s to run in %d ms for resource %s (execution id: %s)",
|
||||
log.debugf("Scheduling step %s to run in %s ms for resource %s (execution id: %s)",
|
||||
step.getProviderId(), step.getAfter(), resourceId, executionId);
|
||||
stateProvider.scheduleStep(workflow, step, resourceId, executionId);
|
||||
return;
|
||||
} else {
|
||||
// Otherwise, run the step right away
|
||||
context.setCurrentStep(step);
|
||||
runWorkflowStep(provider, context);
|
||||
runWorkflowStep(session, provider, context);
|
||||
}
|
||||
}
|
||||
if (context.isRestarted()) {
|
||||
@@ -59,13 +61,13 @@ class RunWorkflowTask extends WorkflowTransactionalTask {
|
||||
stateProvider.remove(executionId);
|
||||
}
|
||||
|
||||
private void runWorkflowStep(DefaultWorkflowProvider provider, DefaultWorkflowExecutionContext context) {
|
||||
private void runWorkflowStep(KeycloakSession session, DefaultWorkflowProvider provider, DefaultWorkflowExecutionContext context) {
|
||||
String executionId = context.getExecutionId();
|
||||
WorkflowStep step = context.getCurrentStep();
|
||||
String resourceId = context.getResourceId();
|
||||
log.debugf("Running step %s on resource %s (execution id: %s)", step.getProviderId(), resourceId, executionId);
|
||||
try {
|
||||
provider.getStepProvider(step).run(context);
|
||||
getStepProvider(session, step).run(context);
|
||||
log.debugf("Step %s completed successfully (execution id: %s)", step.getProviderId(), executionId);
|
||||
} catch(WorkflowExecutionException e) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
@@ -116,7 +116,7 @@ public class Workflow {
|
||||
|
||||
public Stream<WorkflowStep> getSteps() {
|
||||
return realm.getComponentsStream(getId(), WorkflowStepProvider.class.getName())
|
||||
.map(WorkflowStep::new).sorted();
|
||||
.map((c) -> new WorkflowStep(session, c)).sorted();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,4 +19,11 @@ public interface WorkflowExecutionContext {
|
||||
* @return the event bound to the current execution.
|
||||
*/
|
||||
WorkflowEvent getEvent();
|
||||
|
||||
/**
|
||||
* Returns the next step to be executed in the workflow.
|
||||
*
|
||||
* @return the next workflow step
|
||||
*/
|
||||
WorkflowStep getNextStep();
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ import java.util.Objects;
|
||||
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
||||
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER;
|
||||
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY;
|
||||
|
||||
public class WorkflowStep implements Comparable<WorkflowStep> {
|
||||
|
||||
private KeycloakSession session;
|
||||
private String id;
|
||||
private final String providerId;
|
||||
private MultivaluedHashMap<String, String> config;
|
||||
@@ -36,7 +38,8 @@ public class WorkflowStep implements Comparable<WorkflowStep> {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public WorkflowStep(ComponentModel model) {
|
||||
public WorkflowStep(KeycloakSession session, ComponentModel model) {
|
||||
this.session = session;
|
||||
this.id = model.getId();
|
||||
this.providerId = model.getProviderId();
|
||||
this.config = model.getConfig();
|
||||
@@ -89,6 +92,26 @@ public class WorkflowStep implements Comparable<WorkflowStep> {
|
||||
return getConfig().getFirst(CONFIG_AFTER);
|
||||
}
|
||||
|
||||
public String getNotificationSubject() {
|
||||
if (session != null) {
|
||||
WorkflowStepProvider provider = Workflows.getStepProvider(session, this);
|
||||
if (provider != null) {
|
||||
return provider.getNotificationSubject();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getNotificationMessage() {
|
||||
if (session != null) {
|
||||
WorkflowStepProvider provider = Workflows.getStepProvider(session, this);
|
||||
if (provider != null) {
|
||||
return provider.getNotificationMessage();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(WorkflowStep other) {
|
||||
return Integer.compare(this.getPriority(), other.getPriority());
|
||||
|
||||
@@ -21,6 +21,28 @@ import org.keycloak.provider.Provider;
|
||||
|
||||
public interface WorkflowStepProvider extends Provider {
|
||||
|
||||
/**
|
||||
* Run this workflow step.
|
||||
*
|
||||
* @param context the workflow execution context
|
||||
*/
|
||||
void run(WorkflowExecutionContext context);
|
||||
|
||||
/**
|
||||
* Returns the message or the text that should be used as the subject of the email when notifying the user about this step.
|
||||
*
|
||||
* @return the notification subject, or {@code null} if the default subject should be used
|
||||
*/
|
||||
default String getNotificationSubject() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message or the text that should be used as the body of the email when notifying the user about this step.
|
||||
*
|
||||
* @return the notification body, or {@code null} if the default subject should be used
|
||||
*/
|
||||
default String getNotificationMessage() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.keycloak.models.workflow;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
public final class Workflows {
|
||||
|
||||
@@ -20,4 +21,19 @@ public final class Workflows {
|
||||
return providerFactory;
|
||||
}
|
||||
|
||||
public static WorkflowStepProvider getStepProvider(KeycloakSession session, WorkflowStep step) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
return getStepProviderFactory(session, step).create(session, realm.getComponent(step.getId()));
|
||||
}
|
||||
|
||||
private static WorkflowStepProviderFactory<WorkflowStepProvider> getStepProviderFactory(KeycloakSession session, WorkflowStep step) {
|
||||
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
|
||||
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId());
|
||||
|
||||
if (factory == null) {
|
||||
throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId());
|
||||
}
|
||||
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,14 @@ public class DeleteUserStepProvider implements WorkflowStepProvider {
|
||||
userCache.evict(realm, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNotificationMessage() {
|
||||
return "accountDeleteNotificationBody";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNotificationSubject() {
|
||||
return "accountDeleteNotificationSubject";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,14 @@ public class DisableUserStepProvider implements WorkflowStepProvider {
|
||||
user.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNotificationMessage() {
|
||||
return "accountDisableNotificationBody";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNotificationSubject() {
|
||||
return "accountDisableNotificationSubject";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
|
||||
package org.keycloak.models.workflow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.common.util.DurationConverter;
|
||||
import org.keycloak.common.util.StringPropertyReplacer.PropertyResolver;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.email.EmailException;
|
||||
import org.keycloak.email.EmailTemplateProvider;
|
||||
@@ -32,15 +31,10 @@ import org.keycloak.models.UserModel;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER;
|
||||
import static org.keycloak.common.util.StringPropertyReplacer.replaceProperties;
|
||||
|
||||
public class NotifyUserStepProvider implements WorkflowStepProvider {
|
||||
|
||||
private static final String ACCOUNT_DISABLE_NOTIFICATION_SUBJECT = "accountDisableNotificationSubject";
|
||||
private static final String ACCOUNT_DELETE_NOTIFICATION_SUBJECT = "accountDeleteNotificationSubject";
|
||||
private static final String ACCOUNT_DISABLE_NOTIFICATION_BODY = "accountDisableNotificationBody";
|
||||
private static final String ACCOUNT_DELETE_NOTIFICATION_BODY = "accountDeleteNotificationBody";
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final ComponentModel stepModel;
|
||||
private final Logger log = Logger.getLogger(NotifyUserStepProvider.class);
|
||||
@@ -59,9 +53,9 @@ public class NotifyUserStepProvider implements WorkflowStepProvider {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class).setRealm(realm);
|
||||
|
||||
String subjectKey = getSubjectKey();
|
||||
String subjectKey = getSubjectKey(context);
|
||||
String bodyTemplate = getBodyTemplate();
|
||||
Map<String, Object> bodyAttributes = getBodyAttributes();
|
||||
Map<String, Object> bodyAttributes = getBodyAttributes(context);
|
||||
UserModel user = session.users().getUserById(realm, context.getResourceId());
|
||||
|
||||
if (user != null && user.getEmail() != null) {
|
||||
@@ -76,110 +70,109 @@ public class NotifyUserStepProvider implements WorkflowStepProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private String getSubjectKey() {
|
||||
String nextStepType = getNextStepType();
|
||||
String customSubjectKey = stepModel.getConfig().getFirst("custom_subject_key");
|
||||
private String getSubjectKey(WorkflowExecutionContext context) {
|
||||
String customSubjectKey = stepModel.getConfig().getFirst("subject");
|
||||
|
||||
if (customSubjectKey != null && !customSubjectKey.trim().isEmpty()) {
|
||||
return customSubjectKey;
|
||||
}
|
||||
|
||||
// Return default subject key based on next step type
|
||||
return getDefaultSubjectKey(nextStepType);
|
||||
|
||||
WorkflowStep nextStep = context.getNextStep();
|
||||
|
||||
if (nextStep == null || nextStep.getNotificationSubject() == null) {
|
||||
return "accountNotificationSubject";
|
||||
}
|
||||
|
||||
return nextStep.getNotificationSubject();
|
||||
}
|
||||
|
||||
private String getBodyTemplate() {
|
||||
return "workflow-notification.ftl";
|
||||
}
|
||||
|
||||
private Map<String, Object> getBodyAttributes() {
|
||||
private Map<String, Object> getBodyAttributes(WorkflowExecutionContext context) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
String nextStepType = getNextStepType();
|
||||
|
||||
WorkflowStep nextStep = context.getNextStep();
|
||||
|
||||
// Custom message override or default based on step type
|
||||
String customMessage = stepModel.getConfig().getFirst("custom_message");
|
||||
String customMessage = stepModel.getConfig().getFirst("message");
|
||||
if (customMessage != null && !customMessage.trim().isEmpty()) {
|
||||
attributes.put("messageKey", "customMessage");
|
||||
attributes.put("customMessage", customMessage);
|
||||
attributes.put("customMessage", replaceProperties(customMessage, new NotificationPropertyResolver(session, context)));
|
||||
} else if (nextStep != null && nextStep.getNotificationMessage() != null) {
|
||||
attributes.put("messageKey", nextStep.getNotificationMessage());
|
||||
} else {
|
||||
attributes.put("messageKey", getDefaultMessageKey(nextStepType));
|
||||
attributes.put("messageKey", "accountNotificationBody");
|
||||
}
|
||||
|
||||
// Calculate days remaining until next step
|
||||
int daysRemaining = calculateDaysUntilNextStep();
|
||||
int daysRemaining = calculateDaysUntilNextStep(context);
|
||||
|
||||
// Message parameters for internationalization
|
||||
attributes.put("daysRemaining", daysRemaining);
|
||||
attributes.put("reason", stepModel.getConfig().getFirstOrDefault("reason", "inactivity"));
|
||||
attributes.put("realmName", realm.getDisplayName() != null ? realm.getDisplayName() : realm.getName());
|
||||
attributes.put("nextStepType", nextStepType);
|
||||
attributes.put("subjectKey", getSubjectKey());
|
||||
|
||||
if (nextStep != null) {
|
||||
attributes.put("nextStepType", nextStep.getProviderId());
|
||||
}
|
||||
|
||||
attributes.put("subjectKey", getSubjectKey(context));
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private String getNextStepType() {
|
||||
Map<ComponentModel, Duration> nextStepMap = getNextNonNotificationStep();
|
||||
return nextStepMap.isEmpty() ? "unknown-step" : nextStepMap.keySet().iterator().next().getProviderId();
|
||||
}
|
||||
private int calculateDaysUntilNextStep(WorkflowExecutionContext context) {
|
||||
WorkflowStep nextStep = context.getNextStep();
|
||||
|
||||
private int calculateDaysUntilNextStep() {
|
||||
Map<ComponentModel, Duration> nextStepMap = getNextNonNotificationStep();
|
||||
if (nextStepMap.isEmpty()) {
|
||||
if (nextStep == null || nextStep.getAfter() == null) {
|
||||
return 0;
|
||||
}
|
||||
Duration timeToNextStep = nextStepMap.values().iterator().next();
|
||||
return Math.toIntExact(timeToNextStep.toDays());
|
||||
|
||||
return Math.toIntExact(DurationConverter.parseDuration(nextStep.getAfter()).toDays());
|
||||
}
|
||||
|
||||
private Map<ComponentModel, Duration> getNextNonNotificationStep() {
|
||||
Duration timeToNextNonNotificationStep = Duration.ZERO;
|
||||
private class NotificationPropertyResolver implements PropertyResolver {
|
||||
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
ComponentModel workflowModel = realm.getComponent(stepModel.getParentId());
|
||||
|
||||
List<ComponentModel> steps = realm.getComponentsStream(workflowModel.getId(), WorkflowStepProvider.class.getName())
|
||||
.sorted((a, b) -> {
|
||||
int priorityA = Integer.parseInt(a.get("priority", "0"));
|
||||
int priorityB = Integer.parseInt(b.get("priority", "0"));
|
||||
return Integer.compare(priorityA, priorityB);
|
||||
})
|
||||
.toList();
|
||||
|
||||
// Find current step and return next non-notification step
|
||||
boolean foundCurrent = false;
|
||||
for (ComponentModel step : steps) {
|
||||
if (foundCurrent) {
|
||||
Duration duration = DurationConverter.parseDuration(step.get(CONFIG_AFTER, "0"));
|
||||
timeToNextNonNotificationStep = timeToNextNonNotificationStep.plus(duration != null ? duration : Duration.ZERO);
|
||||
if (!step.getProviderId().equals("notify-user")) {
|
||||
// we found the next non-notification action, accumulate its time and break
|
||||
return Map.of(step, timeToNextNonNotificationStep);
|
||||
}
|
||||
}
|
||||
if (step.getId().equals(stepModel.getId())) {
|
||||
foundCurrent = true;
|
||||
}
|
||||
private final KeycloakSession session;
|
||||
private final WorkflowExecutionContext context;
|
||||
|
||||
public NotificationPropertyResolver(KeycloakSession session, WorkflowExecutionContext context) {
|
||||
this.session = session;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private String getDefaultSubjectKey(String stepType) {
|
||||
return switch (stepType) {
|
||||
case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT;
|
||||
case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT;
|
||||
default -> "accountNotificationSubject";
|
||||
};
|
||||
}
|
||||
|
||||
private String getDefaultMessageKey(String stepType) {
|
||||
return switch (stepType) {
|
||||
case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_BODY;
|
||||
case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_BODY;
|
||||
default -> "accountNotificationBody";
|
||||
};
|
||||
@Override
|
||||
public String resolve(String property) {
|
||||
if (property.startsWith("user.")) {
|
||||
String userId = context.getResourceId();
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
UserModel user = session.users().getUserById(realm, userId);
|
||||
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String attributeKey = property.substring("user.".length());
|
||||
|
||||
return user.getFirstAttribute(attributeKey);
|
||||
} else if (property.startsWith("realm.")) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
String attributeKey = property.substring("realm.".length());
|
||||
|
||||
if (attributeKey.equals("name")) {
|
||||
return realm.getName();
|
||||
} else if (attributeKey.equals("displayName")) {
|
||||
return realm.getDisplayName();
|
||||
}
|
||||
|
||||
return null;
|
||||
} else if ("workflow.daysUntilNextStep".equals(property)) {
|
||||
return String.valueOf(calculateDaysUntilNextStep(context));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.tests.admin.model.workflow;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
|
||||
import org.keycloak.models.workflow.NotifyUserStepProviderFactory;
|
||||
import org.keycloak.representations.workflows.WorkflowRepresentation;
|
||||
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
|
||||
import org.keycloak.testframework.annotations.InjectAdminClient;
|
||||
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
import org.keycloak.testframework.mail.MailServer;
|
||||
import org.keycloak.testframework.mail.annotations.InjectMailServer;
|
||||
import org.keycloak.testframework.realm.UserConfigBuilder;
|
||||
import org.keycloak.testframework.server.KeycloakUrls;
|
||||
import org.keycloak.tests.utils.MailUtils;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.keycloak.models.workflow.ResourceOperationType.USER_ADDED;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class)
|
||||
public class NotificationStepTest extends AbstractWorkflowTest {
|
||||
|
||||
@InjectMailServer
|
||||
private MailServer mailServer;
|
||||
|
||||
@InjectKeycloakUrls
|
||||
KeycloakUrls keycloakUrls;
|
||||
|
||||
@InjectAdminClient(ref = "managed", realmRef = "managedRealm")
|
||||
Keycloak adminClient;
|
||||
|
||||
@Test
|
||||
public void testNotifyUserStepWithCustomMessageOverride() throws IOException {
|
||||
// Create workflow: disable at 7 days, notify 2 days before (at day 5) with custom message
|
||||
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow")
|
||||
.onEvent(USER_ADDED.name())
|
||||
.withSteps(
|
||||
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
|
||||
.withConfig("message", "<p>Dear ${user.firstName} ${user.lastName}, </p>\n" +
|
||||
"\n" +
|
||||
" <p>Welcome to ${realm.name}!</p>\n" +
|
||||
" <p>The next step is scheduled to ${workflow.daysUntilNextStep} days.</p>\n" +
|
||||
"\n" +
|
||||
" <p>\n" +
|
||||
" Best regards,<br/>\n" +
|
||||
" ${realm.name} team\n" +
|
||||
" </p> ")
|
||||
.withConfig("subject", "customComplianceSubject")
|
||||
.build(),
|
||||
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
|
||||
.after(Duration.ofDays(7))
|
||||
.build()
|
||||
).build()).close();
|
||||
|
||||
try {
|
||||
managedRealm.admin().users().create(
|
||||
UserConfigBuilder.create()
|
||||
.username("testuser3")
|
||||
.email("test3@example.com")
|
||||
.name("Bob", "Doe")
|
||||
.build()
|
||||
).close();
|
||||
|
||||
MimeMessage message = mailServer.getLastReceivedMessage();
|
||||
assertNotNull(message);
|
||||
|
||||
MailUtils.EmailBody body = MailUtils.getBody(message);
|
||||
|
||||
for (String content : List.of(body.getText(), body.getHtml())) {
|
||||
assertTrue(content.contains("Dear Bob Doe,"));
|
||||
assertTrue(content.contains("Welcome to " + managedRealm.getName() + "!"));
|
||||
assertTrue(content.contains("The next step is scheduled to 7 days."));
|
||||
}
|
||||
} finally {
|
||||
mailServer.runCleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,8 +142,8 @@ public class UserSessionRefreshTimeWorkflowTest extends AbstractWorkflowTest {
|
||||
.withSteps(
|
||||
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
|
||||
.after(Duration.ofDays(5))
|
||||
.withConfig("custom_subject_key", "notifier1_subject")
|
||||
.withConfig("custom_message", "notifier1_message")
|
||||
.withConfig("subject", "notifier1_subject")
|
||||
.withConfig("message", "notifier1_message")
|
||||
.build())
|
||||
.build()).close();
|
||||
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow_2")
|
||||
@@ -151,8 +151,8 @@ public class UserSessionRefreshTimeWorkflowTest extends AbstractWorkflowTest {
|
||||
.withSteps(
|
||||
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
|
||||
.after(Duration.ofDays(10))
|
||||
.withConfig("custom_subject_key", "notifier2_subject")
|
||||
.withConfig("custom_message", "notifier2_message")
|
||||
.withConfig("subject", "notifier2_subject")
|
||||
.withConfig("message", "notifier2_message")
|
||||
.build())
|
||||
.build()).close();
|
||||
|
||||
|
||||
@@ -917,8 +917,8 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
|
||||
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
|
||||
.after(Duration.ofDays(5))
|
||||
.withConfig("reason", "compliance requirement")
|
||||
.withConfig("custom_message", "Your account requires immediate attention due to new compliance policies.")
|
||||
.withConfig("custom_subject_key", "customComplianceSubject")
|
||||
.withConfig("message", "${user.firstName}, your account requires immediate attention due to new compliance policies.")
|
||||
.withConfig("subject", "customComplianceSubject")
|
||||
.build(),
|
||||
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
|
||||
.after(Duration.ofDays(7))
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
<@layout.emailLayout>
|
||||
<h2>${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc}</h2>
|
||||
|
||||
<p>Dear ${user.firstName!user.username},</p>
|
||||
|
||||
<#if messageKey == "customMessage">
|
||||
<p>${kcSanitize(customMessage)?no_esc}</p>
|
||||
<p>${kcSanitize(customMessage)?no_esc}</p>
|
||||
<#else>
|
||||
<p>${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}</p>
|
||||
<p>Dear ${user.firstName!user.username},</p>
|
||||
|
||||
<p>${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}</p>
|
||||
|
||||
<#if daysRemaining gt 0>
|
||||
<p><strong>Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if></strong></p>
|
||||
</#if>
|
||||
|
||||
<p>If you have questions, please contact your ${realmName} administrator.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
${realmName} Administration
|
||||
</p>
|
||||
</#if>
|
||||
|
||||
<#if daysRemaining gt 0>
|
||||
<p><strong>Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if></strong></p>
|
||||
</#if>
|
||||
|
||||
<p>If you have questions, please contact your ${realmName} administrator.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
${realmName} Administration
|
||||
</p>
|
||||
</@layout.emailLayout>
|
||||
@@ -1,18 +1,18 @@
|
||||
${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc}
|
||||
|
||||
Dear ${user.firstName!user.username},
|
||||
|
||||
<#if messageKey == "customMessage">
|
||||
${kcSanitize(customMessage)?no_esc}
|
||||
<#else>
|
||||
Dear ${user.firstName!user.username},
|
||||
|
||||
${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}
|
||||
</#if>
|
||||
|
||||
<#if daysRemaining gt 0>
|
||||
Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if>
|
||||
Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if>
|
||||
</#if>
|
||||
|
||||
If you have questions, please contact your ${realmName} administrator.
|
||||
|
||||
Best regards,
|
||||
${realmName} Administration
|
||||
${realmName} Administration
|
||||
</#if>
|
||||
Reference in New Issue
Block a user