diff --git a/CHANGELOG.md b/CHANGELOG.md index 221bf309ac..955f6313af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for scroll anchors in url hash of diff page ([#1304](https://github.com/scm-manager/scm-manager/pull/1304)) +## [2.4.1] - 2020-09-01 +### Added +- Add "sonia.scm.restart-migration.wait" to set wait in milliseconds before restarting scm-server after migration ([#1308](https://github.com/scm-manager/scm-manager/pull/1308)) + ### Fixed +- Fix detection of markdown files for files having content does not start with '#' ([#1306](https://github.com/scm-manager/scm-manager/pull/1306)) - Fix broken markdown rendering ([#1303](https://github.com/scm-manager/scm-manager/pull/1303)) - JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297)) - Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298)) - Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300)) +- Fix layout overflow on changesets with multiple tags ([#1314](https://github.com/scm-manager/scm-manager/pull/1314)) +- Make checkbox accessible from keyboard ([#1309](https://github.com/scm-manager/scm-manager/pull/1309)) +- Fix logging of large stacktrace for unknown language ([#1313](https://github.com/scm-manager/scm-manager/pull/1313)) +- Fix incorrect word breaking behaviour in markdown ([#1317](https://github.com/scm-manager/scm-manager/pull/1317)) +- Remove obsolete revision encoding on sources ([#1315](https://github.com/scm-manager/scm-manager/pull/1315)) +- Map generic JaxRS 'web application exceptions' to appropriate response instead of "internal server error" ([#1318](https://github.com/scm-manager/scm-manager/pull/1312)) + ## [2.4.0] - 2020-08-14 ### Added @@ -284,3 +296,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [2.3.0]: https://www.scm-manager.org/download/2.3.0 [2.3.1]: https://www.scm-manager.org/download/2.3.1 [2.4.0]: https://www.scm-manager.org/download/2.4.0 +[2.4.1]: https://www.scm-manager.org/download/2.4.1 diff --git a/lerna.json b/lerna.json index c8dcc2f397..4db07d9ee9 100644 --- a/lerna.json +++ b/lerna.json @@ -5,5 +5,5 @@ ], "npmClient": "yarn", "useWorkspaces": true, - "version": "2.4.0" + "version": "2.4.1" } diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index a6514465fd..30e8df9fe7 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -166,6 +166,11 @@ public class AuthenticationFilter extends HttpFilter { HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription()); } + protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException { + throw tokenExpiredException; + } + /** * Iterates all {@link WebTokenGenerator} and creates an * {@link AuthenticationToken} from the given request. @@ -211,7 +216,7 @@ public class AuthenticationFilter extends HttpFilter { processChain(request, response, chain, subject); } catch (TokenExpiredException ex) { // Rethrow to be caught by TokenExpiredFilter - throw ex; + handleTokenExpiredException(request, response, chain, ex); } catch (AuthenticationException ex) { logger.warn("authentication failed", ex); handleUnauthorized(request, response, chain); diff --git a/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java b/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java index 08f845740c..e6716f0013 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBase.java @@ -25,6 +25,7 @@ package sonia.scm.web.filter; import sonia.scm.config.ScmConfiguration; +import sonia.scm.security.TokenExpiredException; import sonia.scm.util.HttpUtil; import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgentParser; @@ -59,4 +60,15 @@ public class HttpProtocolServletAuthenticationFilterBase extends AuthenticationF HttpUtil.sendUnauthorized(request, response); } } + + @Override + protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException { + UserAgent userAgent = userAgentParser.parse(request); + if (userAgent.isBrowser()) { + // we can proceed the filter chain because the HttpProtocolServlet will render the ui if the client is a browser + chain.doFilter(request, response); + } else { + super.handleTokenExpiredException(request, response, chain, tokenExpiredException); + } + } } diff --git a/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java index daa2ad448b..2c49458547 100644 --- a/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java +++ b/scm-core/src/test/java/sonia/scm/web/filter/HttpProtocolServletAuthenticationFilterBaseTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.web.filter; import org.junit.jupiter.api.BeforeEach; @@ -30,6 +30,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.config.ScmConfiguration; +import sonia.scm.security.TokenExpiredException; import sonia.scm.util.HttpUtil; import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgentParser; @@ -43,6 +44,7 @@ import java.io.IOException; import java.util.Collections; import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -93,4 +95,23 @@ class HttpProtocolServletAuthenticationFilterBaseTest { verify(filterChain).doFilter(request, response); } + @Test + void shouldIgnoreTokenExpiredExceptionForBrowserCall() throws IOException, ServletException { + when(userAgentParser.parse(request)).thenReturn(browser); + + authenticationFilter.handleTokenExpiredException(request, response, filterChain, new TokenExpiredException("Nothing ever expired so much")); + + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldRethrowTokenExpiredExceptionForApiCall() { + when(userAgentParser.parse(request)).thenReturn(nonBrowser); + + final TokenExpiredException tokenExpiredException = new TokenExpiredException("Nothing ever expired so much"); + + assertThrows(TokenExpiredException.class, + () -> authenticationFilter.handleTokenExpiredException(request, response, filterChain, tokenExpiredException)); + } + } diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 1f54c19fb2..0a789bde38 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-git-plugin", "private": true, - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "main": "./src/main/js/index.ts", "scripts": { @@ -20,6 +20,6 @@ }, "prettier": "@scm-manager/prettier-config", "dependencies": { - "@scm-manager/ui-plugins": "^2.4.0" + "@scm-manager/ui-plugins": "^2.4.1" } } diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json index 9217810a50..c67c73bd4b 100644 --- a/scm-plugins/scm-hg-plugin/package.json +++ b/scm-plugins/scm-hg-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-hg-plugin", "private": true, - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "main": "./src/main/js/index.ts", "scripts": { @@ -19,6 +19,6 @@ }, "prettier": "@scm-manager/prettier-config", "dependencies": { - "@scm-manager/ui-plugins": "^2.4.0" + "@scm-manager/ui-plugins": "^2.4.1" } } diff --git a/scm-plugins/scm-legacy-plugin/package.json b/scm-plugins/scm-legacy-plugin/package.json index 7c127ec21c..e5d158a9fb 100644 --- a/scm-plugins/scm-legacy-plugin/package.json +++ b/scm-plugins/scm-legacy-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-legacy-plugin", "private": true, - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "main": "./src/main/js/index.tsx", "scripts": { @@ -19,6 +19,6 @@ }, "prettier": "@scm-manager/prettier-config", "dependencies": { - "@scm-manager/ui-plugins": "^2.4.0" + "@scm-manager/ui-plugins": "^2.4.1" } } diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json index 03ae37d3dc..978e2296d1 100644 --- a/scm-plugins/scm-svn-plugin/package.json +++ b/scm-plugins/scm-svn-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/scm-svn-plugin", "private": true, - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "main": "./src/main/js/index.ts", "scripts": { @@ -19,6 +19,6 @@ }, "prettier": "@scm-manager/prettier-config", "dependencies": { - "@scm-manager/ui-plugins": "^2.4.0" + "@scm-manager/ui-plugins": "^2.4.1" } } diff --git a/scm-ui/e2e-tests/package.json b/scm-ui/e2e-tests/package.json index d5348843d0..5ba065dbf4 100644 --- a/scm-ui/e2e-tests/package.json +++ b/scm-ui/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/e2e-tests", - "version": "2.4.0", + "version": "2.4.1", "description": "End to end Tests for SCM-Manager", "main": "index.js", "author": "Eduard Heimbuch ", diff --git a/scm-ui/ui-components/.storybook/config.js b/scm-ui/ui-components/.storybook/config.js index ae409e6478..af032d4054 100644 --- a/scm-ui/ui-components/.storybook/config.js +++ b/scm-ui/ui-components/.storybook/config.js @@ -29,6 +29,7 @@ import { withI18next } from "storybook-addon-i18next"; import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss"; import React from "react"; import { MemoryRouter } from "react-router-dom"; +import withRedux from "./withRedux"; let i18n = i18next; @@ -70,4 +71,6 @@ addDecorator( }) ); +addDecorator(withRedux); + configure(require.context("../src", true, /\.stories\.tsx?$/), module); diff --git a/scm-ui/ui-components/.storybook/withRedux.js b/scm-ui/ui-components/.storybook/withRedux.js new file mode 100644 index 0000000000..0abc2149ca --- /dev/null +++ b/scm-ui/ui-components/.storybook/withRedux.js @@ -0,0 +1,41 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from "react"; +import {createStore} from "redux"; +import { Provider } from 'react-redux' + +const reducer = (state, action) => { + return state; +}; + +const withRedux = (storyFn) => { + return React.createElement(Provider, { + store: createStore(reducer, {}), + children: storyFn() + }); +} + + +export default withRedux; diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index 54606ae27a..f6a51262eb 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-components", - "version": "2.4.0", + "version": "2.4.1", "description": "UI Components for SCM-Manager and its plugins", "main": "src/index.ts", "files": [ diff --git a/scm-ui/ui-components/src/ErrorBoundary.tsx b/scm-ui/ui-components/src/ErrorBoundary.tsx index e8eda0d366..33c8a48494 100644 --- a/scm-ui/ui-components/src/ErrorBoundary.tsx +++ b/scm-ui/ui-components/src/ErrorBoundary.tsx @@ -21,14 +21,24 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { ReactNode } from "react"; +import React, { ComponentType, ReactNode } from "react"; import ErrorNotification from "./ErrorNotification"; +import { MissingLinkError } from "./errors"; +import { withContextPath } from "./urls"; +import { withRouter, RouteComponentProps } from "react-router-dom"; +import ErrorPage from "./ErrorPage"; +import { WithTranslation, withTranslation } from "react-i18next"; +import { compose } from "redux"; +import { connect } from "react-redux"; -type Props = { +type ExportedProps = { fallback?: React.ComponentType; children: ReactNode; + loginLink?: string; }; +type Props = WithTranslation & RouteComponentProps & ExportedProps; + type ErrorInfo = { componentStack: string; }; @@ -44,16 +54,44 @@ class ErrorBoundary extends React.Component { this.state = {}; } - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Catch errors in any components below and re-render with error message - this.setState({ - error, - errorInfo - }); + componentDidUpdate(prevProps: Readonly) { + // we must reset the error if the url has changed + if (this.state.error && prevProps.location !== this.props.location) { + this.setState({ error: undefined, errorInfo: undefined }); + } } + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState( + { + error, + errorInfo + }, + () => this.redirectToLogin(error) + ); + } + + redirectToLogin = (error: Error) => { + const { loginLink } = this.props; + if (error instanceof MissingLinkError) { + if (loginLink) { + window.location.assign(withContextPath("/login")); + } + } + }; + renderError = () => { + const { t } = this.props; + const { error } = this.state; + let FallbackComponent = this.props.fallback; + + if (error instanceof MissingLinkError) { + return ( + + ); + } + if (!FallbackComponent) { FallbackComponent = ErrorNotification; } @@ -69,4 +107,17 @@ class ErrorBoundary extends React.Component { return this.props.children; } } -export default ErrorBoundary; + +const mapStateToProps = (state: any) => { + const loginLink = state.indexResources?.links?.login?.href; + + return { + loginLink + }; +}; + +export default compose>( + withRouter, + withTranslation("commons"), + connect(mapStateToProps) +)(ErrorBoundary); diff --git a/scm-ui/ui-components/src/ErrorNotification.tsx b/scm-ui/ui-components/src/ErrorNotification.tsx index 00524efa4e..c43ef73119 100644 --- a/scm-ui/ui-components/src/ErrorNotification.tsx +++ b/scm-ui/ui-components/src/ErrorNotification.tsx @@ -21,16 +21,26 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC } from "react"; +import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; import { BackendError, ForbiddenError, UnauthorizedError } from "./errors"; import Notification from "./Notification"; import BackendErrorNotification from "./BackendErrorNotification"; +import { useLocation } from "react-router-dom"; +import { withContextPath } from "./urls"; type Props = WithTranslation & { error?: Error; }; +const LoginLink: FC = () => { + const [t] = useTranslation("commons"); + const location = useLocation(); + const from = encodeURIComponent(location.pathname); + + return {t("errorNotification.loginLink")}; +}; + class ErrorNotification extends React.Component { render() { const { t, error } = this.props; @@ -40,8 +50,7 @@ class ErrorNotification extends React.Component { } else if (error instanceof UnauthorizedError) { return ( - {t("errorNotification.prefix")}: {t("errorNotification.timeout")}{" "} - {t("errorNotification.loginLink")} + {t("errorNotification.prefix")}: {t("errorNotification.timeout")} ); } else if (error instanceof ForbiddenError) { diff --git a/scm-ui/ui-components/src/ProtectedRoute.tsx b/scm-ui/ui-components/src/ProtectedRoute.tsx index b83271fdb8..c371c49892 100644 --- a/scm-ui/ui-components/src/ProtectedRoute.tsx +++ b/scm-ui/ui-components/src/ProtectedRoute.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React, { Component } from "react"; -import { Route, Redirect, withRouter, RouteComponentProps, RouteProps } from "react-router-dom"; +import { Redirect, Route, RouteComponentProps, RouteProps, withRouter } from "react-router-dom"; type Props = RouteComponentProps & RouteProps & { @@ -30,7 +30,16 @@ type Props = RouteComponentProps & }; class ProtectedRoute extends Component { - renderRoute = (Component: any, authenticated?: boolean) => { + constructor(props: Props) { + super(props); + this.state = { + error: undefined + }; + } + + renderRoute = (Component: any) => { + const { authenticated } = this.props; + return (routeProps: any) => { if (authenticated) { return ; @@ -50,8 +59,8 @@ class ProtectedRoute extends Component { }; render() { - const { component, authenticated, ...routeProps } = this.props; - return ; + const { component, ...routeProps } = this.props; + return ; } } diff --git a/scm-ui/ui-components/src/Tooltip.tsx b/scm-ui/ui-components/src/Tooltip.tsx index 98dce960d2..9277ad0d01 100644 --- a/scm-ui/ui-components/src/Tooltip.tsx +++ b/scm-ui/ui-components/src/Tooltip.tsx @@ -22,12 +22,12 @@ * SOFTWARE. */ import React, { ReactNode } from "react"; -import classNames from "classnames"; type Props = { message: string; className?: string; location: string; + multiline?: boolean; children: ReactNode; }; @@ -37,9 +37,17 @@ class Tooltip extends React.Component { }; render() { - const { className, message, location, children } = this.props; + const { className, message, location, multiline, children } = this.props; + let classes = `tooltip has-tooltip-${location}`; + if (multiline) { + classes += " has-tooltip-multiline"; + } + if (className) { + classes += " " + className; + } + return ( - + {children} ); diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 3a1290349d..a475ea15fd 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -44140,14 +44140,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
@@ -44158,14 +44164,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
@@ -44176,14 +44188,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
@@ -44201,15 +44219,21 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
@@ -44227,14 +44251,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `