diff --git a/README.md b/README.md index e69632d..bf412be 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,32 @@ spring: server: port: 8081 + servlet: + context-path: / +``` + +#### Change the Context-Path + +The context-path or base-path of the application can be changed using the following property: + +``` +server: + servlet: + context-path: /tasklist/ +``` + +It is then available under http://localhost:8081/tasklist. + +#### Customize the Look & Feel + +You can customize the look & feel of the Zeebe Simple Tasklist (aka. white-labeling). For example, to change the logo or +alter the background color. The following configurations are available: + +``` +- white-label.logo.path=img/logo.png +- white-label.custom.title=Zeebe Simple Tasklist +- white-label.custom.css.path=css/custom.css +- white-label.custom.js.path=js/custom.js ``` #### Change the Database diff --git a/pom.xml b/pom.xml index 624086b..a9feb8f 100644 --- a/pom.xml +++ b/pom.xml @@ -145,8 +145,8 @@ org.webjars - webjars-locator-core - 0.52 + webjars-locator + 0.42 org.webjars diff --git a/src/main/java/io/zeebe/tasklist/WebSecurityConfig.java b/src/main/java/io/zeebe/tasklist/WebSecurityConfig.java index bb35398..98f2ebf 100644 --- a/src/main/java/io/zeebe/tasklist/WebSecurityConfig.java +++ b/src/main/java/io/zeebe/tasklist/WebSecurityConfig.java @@ -21,9 +21,9 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() - .antMatchers("/css/**", "/img/**", "/js/**", "/fonts/**", "/favicon.ico") + .antMatchers("/css/**", "/img/**", "/js/**", "/fonts/**", "/favicon.ico", "/webjars/**") .permitAll() - .antMatchers("/login", "/login-error") + .antMatchers("/login", "/login-error", "/notifications/**") .permitAll() .antMatchers("/views/users", "/api/users", "/views/groups", "/api/groups") .hasRole(Roles.ADMIN.name()) diff --git a/src/main/java/io/zeebe/tasklist/view/AbstractViewController.java b/src/main/java/io/zeebe/tasklist/view/AbstractViewController.java new file mode 100644 index 0000000..26207c9 --- /dev/null +++ b/src/main/java/io/zeebe/tasklist/view/AbstractViewController.java @@ -0,0 +1,59 @@ +package io.zeebe.tasklist.view; + +import io.zeebe.tasklist.Roles; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +abstract class AbstractViewController { + + private static final int FIRST_PAGE = 0; + private static final int PAGE_RANGE = 2; + + @Autowired private WhitelabelProperties whitelabelProperties; + @Autowired private WhitelabelPropertiesMapper whitelabelPropertiesMapper; + + protected void addPaginationToModel( + final Map model, final Pageable pageable, final long count) { + + final int currentPage = pageable.getPageNumber(); + model.put("currentPage", currentPage); + model.put("page", currentPage + 1); + if (currentPage > 0) { + model.put("prevPage", currentPage - 1); + } + if (count > (1 + currentPage) * pageable.getPageSize()) { + model.put("nextPage", currentPage + 1); + } + } + + /* + * Needs to be added manually, since Spring does not detect @ModelAttribute in abstract classes. + */ + protected void addDefaultAttributesToModel(Map model) { + whitelabelPropertiesMapper.addPropertiesToModel(model, whitelabelProperties); + } + + protected void addCommonsToModel(Map model) { + + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + final List authorities = + authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + final UserDto userDto = new UserDto(); + userDto.setName(authentication.getName()); + + final boolean isAdmin = authorities.contains("ROLE_" + Roles.ADMIN); + userDto.setAdmin(isAdmin); + + model.put("user", userDto); + } +} diff --git a/src/main/java/io/zeebe/tasklist/view/ErrorMessage.java b/src/main/java/io/zeebe/tasklist/view/ErrorMessage.java new file mode 100644 index 0000000..f45d1fe --- /dev/null +++ b/src/main/java/io/zeebe/tasklist/view/ErrorMessage.java @@ -0,0 +1,18 @@ +package io.zeebe.tasklist.view; + +public class ErrorMessage { + + private String message; + + public ErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(final String message) { + this.message = message; + } +} diff --git a/src/main/java/io/zeebe/tasklist/view/ExceptionHandler.java b/src/main/java/io/zeebe/tasklist/view/ExceptionHandler.java new file mode 100644 index 0000000..ab55fbb --- /dev/null +++ b/src/main/java/io/zeebe/tasklist/view/ExceptionHandler.java @@ -0,0 +1,50 @@ +package io.zeebe.tasklist.view; + + +import io.camunda.zeebe.client.api.command.ClientException; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +@ControllerAdvice +public class ExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ExceptionHandler.class); + + private final WhitelabelProperties whitelabelProperties; + private final WhitelabelPropertiesMapper whitelabelPropertiesMapper; + + public ExceptionHandler( + WhitelabelProperties whitelabelProperties, + WhitelabelPropertiesMapper whitelabelPropertiesMapper) { + this.whitelabelProperties = whitelabelProperties; + this.whitelabelPropertiesMapper = whitelabelPropertiesMapper; + } + + @org.springframework.web.bind.annotation.ExceptionHandler(value = {ClientException.class}) + protected ResponseEntity handleZeebeClientException( + final RuntimeException ex, final WebRequest request) { + LOG.debug("Zeebe Client Exception caught and forwarding to UI.", ex); + return ResponseEntity.status(HttpStatus.FAILED_DEPENDENCY) + .contentType(MediaType.APPLICATION_JSON) + .body(new ErrorMessage(ex.getMessage())); + } + + @org.springframework.web.bind.annotation.ExceptionHandler(RuntimeException.class) + public String handleRuntimeException(final RuntimeException exc, final Model model) { + LOG.error(exc.getMessage(), exc); + + model.addAttribute("error", exc.getClass().getSimpleName()); + model.addAttribute("message", exc.getMessage()); + model.addAttribute("trace", ExceptionUtils.getStackTrace(exc)); + + whitelabelPropertiesMapper.addPropertiesToModel(model, whitelabelProperties); + return "error"; + } +} diff --git a/src/main/java/io/zeebe/tasklist/view/LoginController.java b/src/main/java/io/zeebe/tasklist/view/LoginController.java index dcd36a1..af33531 100644 --- a/src/main/java/io/zeebe/tasklist/view/LoginController.java +++ b/src/main/java/io/zeebe/tasklist/view/LoginController.java @@ -5,10 +5,12 @@ import org.springframework.web.bind.annotation.GetMapping; @Controller -public class LoginController { +public class LoginController extends AbstractViewController { @GetMapping("/login") public String login(Map model) { + + addDefaultAttributesToModel(model); return "login"; } @@ -16,7 +18,7 @@ public String login(Map model) { public String loginError(Map model) { model.put("error", "Username or password is invalid."); - + addDefaultAttributesToModel(model); return "login"; } } diff --git a/src/main/java/io/zeebe/tasklist/view/NotificationService.java b/src/main/java/io/zeebe/tasklist/view/NotificationService.java index 8d4a2b4..d5317ff 100644 --- a/src/main/java/io/zeebe/tasklist/view/NotificationService.java +++ b/src/main/java/io/zeebe/tasklist/view/NotificationService.java @@ -1,24 +1,30 @@ package io.zeebe.tasklist.view; -import io.zeebe.tasklist.repository.TaskRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @Controller public class NotificationService { + private final String basePath; + + public NotificationService(@Value("${server.servlet.context-path}") final String basePath) { + this.basePath = basePath.endsWith("/") ? basePath : basePath + "/"; + } + @Autowired private SimpMessagingTemplate webSocket; public void sendNewTask() { final TaskNotification notification = new TaskNotification("new tasks"); - webSocket.convertAndSend("/notifications/tasks", notification); + webSocket.convertAndSend(basePath +"notifications/tasks", notification); } public void sendTaskCanceled() { final TaskNotification notification = new TaskNotification("tasks canceled"); - webSocket.convertAndSend("/notifications/tasks", notification); + webSocket.convertAndSend(basePath +"notifications/tasks", notification); } } diff --git a/src/main/java/io/zeebe/tasklist/view/ViewController.java b/src/main/java/io/zeebe/tasklist/view/ViewController.java index f1dbe9d..2c2b767 100644 --- a/src/main/java/io/zeebe/tasklist/view/ViewController.java +++ b/src/main/java/io/zeebe/tasklist/view/ViewController.java @@ -2,7 +2,6 @@ import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Template; -import io.zeebe.tasklist.Roles; import io.zeebe.tasklist.TaskDataSerializer; import io.zeebe.tasklist.entity.GroupEntity; import io.zeebe.tasklist.entity.TaskEntity; @@ -10,35 +9,29 @@ import io.zeebe.tasklist.repository.GroupRepository; import io.zeebe.tasklist.repository.TaskRepository; import io.zeebe.tasklist.repository.UserRepository; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import javax.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.servlet.view.RedirectView; +import javax.annotation.PostConstruct; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + @Controller -public class ViewController { +public class ViewController extends AbstractViewController { private final TaskDataSerializer serializer = new TaskDataSerializer(); @@ -73,7 +66,7 @@ public void loadTemplate() { @GetMapping("/") public RedirectView index() { - return new RedirectView("/views/all-tasks/"); + return new RedirectView("./views/all-tasks/"); } @GetMapping("/views/my-tasks") @@ -91,6 +84,7 @@ public String taskList(Map model, @PageableDefault(size = 10) Pa model.put("count", count); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "task-list-view"; @@ -127,6 +121,7 @@ public String taskList( model.put("count", count); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "task-list-view"; @@ -230,6 +225,7 @@ public String allTaskList( model.put("count", count); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "task-list-view"; @@ -266,6 +262,7 @@ public String allTaskList( model.put("count", count); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "task-list-view"; @@ -300,6 +297,7 @@ public String userList(Map model, @PageableDefault(size = 10) Pa model.put("availableGroups", groupNames); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "user-view"; @@ -320,42 +318,12 @@ public String groupList( model.put("count", count); addPaginationToModel(model, pageable, count); + addDefaultAttributesToModel(model); addCommonsToModel(model); return "group-view"; } - private void addPaginationToModel( - Map model, Pageable pageable, final long count) { - - final int currentPage = pageable.getPageNumber(); - model.put("currentPage", currentPage); - model.put("page", currentPage + 1); - if (currentPage > 0) { - model.put("prevPage", currentPage - 1); - } - if (count > (1 + currentPage) * pageable.getPageSize()) { - model.put("nextPage", currentPage + 1); - } - } - - private void addCommonsToModel(Map model) { - - final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - final List authorities = - authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toList()); - - final UserDto userDto = new UserDto(); - userDto.setName(authentication.getName()); - - final boolean isAdmin = authorities.contains("ROLE_" + Roles.ADMIN); - userDto.setAdmin(isAdmin); - - model.put("user", userDto); - } - private String getUsername() { final String username = SecurityContextHolder.getContext().getAuthentication().getName(); return username; diff --git a/src/main/java/io/zeebe/tasklist/view/WhitelabelProperties.java b/src/main/java/io/zeebe/tasklist/view/WhitelabelProperties.java new file mode 100644 index 0000000..9dacd59 --- /dev/null +++ b/src/main/java/io/zeebe/tasklist/view/WhitelabelProperties.java @@ -0,0 +1,47 @@ +package io.zeebe.tasklist.view; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class WhitelabelProperties { + + private final String basePath; + private final String logoPath; + private final String customCssPath; + private final String customJsPath; + private final String customTitle; + + public WhitelabelProperties( + @Value("${server.servlet.context-path}") final String basePath, + @Value("${white-label.logo.path}") final String logoPath, + @Value("${white-label.custom.title}") final String customTitle, + @Value("${white-label.custom.css.path}") final String customCssPath, + @Value("${white-label.custom.js.path}") final String customJsPath) { + this.basePath = basePath.endsWith("/") ? basePath : basePath + "/"; + this.logoPath = logoPath; + this.customTitle = customTitle; + this.customCssPath = customCssPath; + this.customJsPath = customJsPath; + } + + public String getBasePath() { + return basePath; + } + + public String getLogoPath() { + return logoPath; + } + + public String getCustomCssPath() { + return customCssPath; + } + + public String getCustomJsPath() { + return customJsPath; + } + + public String getCustomTitle() { + return customTitle; + } +} diff --git a/src/main/java/io/zeebe/tasklist/view/WhitelabelPropertiesMapper.java b/src/main/java/io/zeebe/tasklist/view/WhitelabelPropertiesMapper.java new file mode 100644 index 0000000..7cdd0fe --- /dev/null +++ b/src/main/java/io/zeebe/tasklist/view/WhitelabelPropertiesMapper.java @@ -0,0 +1,33 @@ +package io.zeebe.tasklist.view; + +import org.springframework.stereotype.Component; +import org.springframework.ui.Model; + +import java.util.Map; + +@Component +public class WhitelabelPropertiesMapper { + + public static final String CUSTOM_TITLE = "custom-title"; + public static final String CONTEXT_PATH = "context-path"; + public static final String LOGO_PATH = "logo-path"; + public static final String CUSTOM_CSS_PATH = "custom-css-path"; + public static final String CUSTOM_JS_PATH = "custom-js-path"; + + public void addPropertiesToModel(Model model, WhitelabelProperties whitelabelProperties) { + model.addAttribute(CUSTOM_TITLE, whitelabelProperties.getCustomTitle()); + model.addAttribute(CONTEXT_PATH, whitelabelProperties.getBasePath()); + model.addAttribute(LOGO_PATH, whitelabelProperties.getLogoPath()); + model.addAttribute(CUSTOM_CSS_PATH, whitelabelProperties.getCustomCssPath()); + model.addAttribute(CUSTOM_JS_PATH, whitelabelProperties.getCustomJsPath()); + } + + public void addPropertiesToModel( + Map model, WhitelabelProperties whitelabelProperties) { + model.put(CUSTOM_TITLE, whitelabelProperties.getCustomTitle()); + model.put(CONTEXT_PATH, whitelabelProperties.getBasePath()); + model.put(LOGO_PATH, whitelabelProperties.getLogoPath()); + model.put(CUSTOM_CSS_PATH, whitelabelProperties.getCustomCssPath()); + model.put(CUSTOM_JS_PATH, whitelabelProperties.getCustomJsPath()); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 103bbd9..05cf049 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -46,9 +46,19 @@ spring: server: port: 8081 + servlet: + context-path: / logging: level: root: ERROR io.camunda: INFO - io.zeebe.tasklist: DEBUG \ No newline at end of file + io.zeebe.tasklist: DEBUG + com.hazelcast: WARN + +white-label: + logo.path: img/logo.png + custom: + title: Zeebe Simple Tasklist + css.path: css/custom.css + js.path: js/custom.js \ No newline at end of file diff --git a/src/main/resources/public/css/custom.css b/src/main/resources/public/css/custom.css new file mode 100644 index 0000000..c610922 --- /dev/null +++ b/src/main/resources/public/css/custom.css @@ -0,0 +1,2 @@ +/* empty by default */ +/* when deployed via Docker or Helm, this file can be replaced with custom CSS */ diff --git a/src/main/resources/public/js/app.js b/src/main/resources/public/js/app.js index 661e6d9..1f2aa22 100644 --- a/src/main/resources/public/js/app.js +++ b/src/main/resources/public/js/app.js @@ -23,6 +23,10 @@ function showErrorResonse(xhr, ajaxOptions, thrownError) { } } +function buildPath(resource) { + return base_path + resource; +} + // -------------------------------------------------------------------- function reload() { @@ -33,8 +37,7 @@ function reload() { function withSecurityToken(url) { var csrf = document.getElementById("_csrf").value; - - return url + '?_csrf=' + csrf + return base_path + url + '?_csrf=' + csrf } // -------------------------------------------------------------------- @@ -42,10 +45,10 @@ function withSecurityToken(url) { var stompClient = null; function connect() { - var socket = new SockJS('/notifications'); + var socket = new SockJS(buildPath('notifications')); stompClient = Stomp.over(socket); stompClient.connect({}, function(frame) { - stompClient.subscribe('/notifications/tasks', function(message) { + stompClient.subscribe(buildPath('notifications/tasks'), function(message) { handleMessage(JSON.parse(message.body)); }); }); @@ -58,7 +61,7 @@ function disconnect() { } function sendMessage(msg) { - stompClient.send("/notifications", {}, + stompClient.send(buildPath('notifications'), {}, JSON.stringify(msg)); } @@ -75,7 +78,7 @@ function completeTask(data) { $.ajax({ type : 'PUT', - url: withSecurityToken('/api/tasks/' + taskKey + '/complete'), + url: withSecurityToken('api/tasks/' + taskKey + '/complete'), data: JSON.stringify(data), contentType: 'application/json; charset=utf-8', success: function (result) { @@ -95,7 +98,7 @@ function claimTask(taskKey) { $.ajax({ type : 'PUT', - url: withSecurityToken('/api/tasks/' + taskKey + '/claim'), + url: withSecurityToken('api/tasks/' + taskKey + '/claim'), contentType: 'application/json; charset=utf-8', success: function (result) { showSuccess("Task claimed. (Key: " + taskKey + ")"); @@ -122,7 +125,7 @@ function createUser() { $.ajax({ type : 'POST', - url: withSecurityToken('/api/users/'), + url: withSecurityToken('api/users/'), data: JSON.stringify(data), contentType: 'application/json; charset=utf-8', success: function (result) { @@ -142,7 +145,7 @@ function deleteUser(username) { $.ajax({ type : 'DELETE', - url: withSecurityToken('/api/users/' + username), + url: withSecurityToken('api/users/' + username), contentType: 'application/json; charset=utf-8', success: function (result) { showSuccess("User deleted. (Username: " + username + ")"); @@ -172,7 +175,7 @@ function updateGroupMemberships(username) { $.ajax({ type : 'PUT', - url: withSecurityToken('/api/users/' + username + '/group-memberships'), + url: withSecurityToken('api/users/' + username + '/group-memberships'), data: JSON.stringify(groups), contentType: 'application/json; charset=utf-8', success: function (result) { @@ -194,7 +197,7 @@ function createGroup() { $.ajax({ type : 'POST', - url: withSecurityToken('/api/groups/'), + url: withSecurityToken('api/groups/'), data: name, contentType: 'application/json; charset=utf-8', success: function (result) { @@ -214,7 +217,7 @@ function deleteGroup(name) { $.ajax({ type : 'DELETE', - url: withSecurityToken('/api/groups/' + name), + url: withSecurityToken('api/groups/' + name), contentType: 'application/json; charset=utf-8', success: function (result) { showSuccess("Group deleted. (Name: " + name + ")"); diff --git a/src/main/resources/public/js/custom.js b/src/main/resources/public/js/custom.js new file mode 100644 index 0000000..ec95849 --- /dev/null +++ b/src/main/resources/public/js/custom.js @@ -0,0 +1,2 @@ +/* empty by default */ +/* when deployed via Docker or Helm, this file can be replaced with custom JS */ diff --git a/src/main/resources/templates/layout/footer.html b/src/main/resources/templates/layout/footer.html index 03a19de..2ca653a 100644 --- a/src/main/resources/templates/layout/footer.html +++ b/src/main/resources/templates/layout/footer.html @@ -1,14 +1,17 @@ + + - - - + + + - - + + - + + \ No newline at end of file diff --git a/src/main/resources/templates/layout/header.html b/src/main/resources/templates/layout/header.html index dd3ddd9..c15ecfb 100644 --- a/src/main/resources/templates/layout/header.html +++ b/src/main/resources/templates/layout/header.html @@ -5,42 +5,43 @@ - Zeebe Simple Tasklist + {{custom-title}} - - + + - + + - +