Improvements to the notify step

Closes #44708

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2025-12-05 14:58:03 -03:00
committed by GitHub
parent 46e5979b17
commit 985777ebcc
16 changed files with 305 additions and 126 deletions

View File

@@ -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;
}

View File

@@ -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()));

View File

@@ -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();

View File

@@ -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();
}
/**

View File

@@ -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();
}

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -48,4 +48,14 @@ public class DisableUserStepProvider implements WorkflowStepProvider {
user.setEnabled(false);
}
}
@Override
public String getNotificationMessage() {
return "accountDisableNotificationBody";
}
@Override
public String getNotificationSubject() {
return "accountDisableNotificationSubject";
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();

View File

@@ -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))

View File

@@ -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>

View File

@@ -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>