merge with 2.0.0-m3

This commit is contained in:
Sebastian Sdorra
2019-08-19 09:53:43 +02:00
21 changed files with 456 additions and 116 deletions

View File

@@ -75,6 +75,11 @@ public class ScmConfiguration implements Configuration {
public static final String DEFAULT_PLUGINURL =
"http://download.scm-manager.org/api/v2/plugins.json?os={os}&arch={arch}&snapshot=false&version={version}";
/**
* Default url for login information (plugin and feature tips on the login page).
*/
public static final String DEFAULT_LOGIN_INFO_URL = "https://login-info.scm-manager.org/api/v1/login-info";
/**
* Default plugin url from version 1.0
*/
@@ -177,6 +182,9 @@ public class ScmConfiguration implements Configuration {
@XmlElement(name = "namespace-strategy")
private String namespaceStrategy = "UsernameNamespaceStrategy";
@XmlElement(name = "login-info-url")
private String loginInfoUrl = DEFAULT_LOGIN_INFO_URL;
/**
* Calls the {@link sonia.scm.ConfigChangedListener#configChanged(Object)}
@@ -216,6 +224,7 @@ public class ScmConfiguration implements Configuration {
this.loginAttemptLimitTimeout = other.loginAttemptLimitTimeout;
this.enabledXsrfProtection = other.enabledXsrfProtection;
this.namespaceStrategy = other.namespaceStrategy;
this.loginInfoUrl = other.loginInfoUrl;
}
/**
@@ -350,6 +359,9 @@ public class ScmConfiguration implements Configuration {
return namespaceStrategy;
}
public String getLoginInfoUrl() {
return loginInfoUrl;
}
/**
* Returns true if failed authenticators are skipped.
@@ -477,6 +489,10 @@ public class ScmConfiguration implements Configuration {
this.namespaceStrategy = namespaceStrategy;
}
public void setLoginInfoUrl(String loginInfoUrl) {
this.loginInfoUrl = loginInfoUrl;
}
@Override
// Only for permission checks, don't serialize to XML
@XmlTransient

View File

@@ -60,6 +60,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
Command.BROWSE,
Command.CAT,
Command.DIFF,
Command.DIFF_RESULT,
Command.LOG,
Command.TAGS,
Command.BRANCHES,
@@ -168,6 +169,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitDiffCommand(context, repository);
}
@Override
public DiffResultCommand getDiffResultCommand() {
return new GitDiffResultCommand(context, repository);
}
/**
* Method description
*

View File

@@ -21,5 +21,6 @@ export type Config = {
loginAttemptLimitTimeout: number,
enabledXsrfProtection: boolean,
namespaceStrategy: string,
loginInfoUrl: string,
_links: Links
};

View File

@@ -5,7 +5,11 @@
"logo-alt": "SCM-Manager",
"username-placeholder": "Benutzername",
"password-placeholder": "Passwort",
"submit": "Anmelden"
"submit": "Anmelden",
"plugin": "Plugin",
"feature": "Feature",
"tip": "Tipp",
"loading": "Lade Daten ..."
},
"logout": {
"error": {

View File

@@ -43,7 +43,8 @@
"skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen",
"plugin-url": "Plugin URL",
"enabled-xsrf-protection": "XSRF Protection aktivieren",
"namespace-strategy": "Namespace Strategie"
"namespace-strategy": "Namespace Strategie",
"login-info-url": "Login Info URL"
},
"validation": {
"date-format-invalid": "Das Datumsformat ist ungültig",
@@ -73,6 +74,7 @@
"proxyUserHelpText": "Der Benutzername für die Proxy Server Anmeldung.",
"proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.",
"enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.",
"nameSpaceStrategyHelpText": "Strategie für Namespaces."
"nameSpaceStrategyHelpText": "Strategie für Namespaces.",
"loginInfoUrlHelpText": "URL zu der Login Information (Plugin und Feature Tipps auf der Login Seite). Um die Login Information zu deaktivieren, kann das Feld leer gelassen werden."
}
}

View File

@@ -5,7 +5,12 @@
"logo-alt": "SCM-Manager",
"username-placeholder": "Your Username",
"password-placeholder": "Your Password",
"submit": "Login"
"submit": "Login",
"plugin": "Plugin",
"feature": "Feature",
"tip": "Tip",
"loading": "Loading ...",
"error": "Error"
},
"logout": {
"error": {

View File

@@ -43,7 +43,8 @@
"skip-failed-authenticators": "Skip Failed Authenticators",
"plugin-url": "Plugin URL",
"enabled-xsrf-protection": "Enabled XSRF Protection",
"namespace-strategy": "Namespace Strategy"
"namespace-strategy": "Namespace Strategy",
"login-info-url": "Login Info URL"
},
"validation": {
"date-format-invalid": "The date format is not valid",
@@ -73,6 +74,7 @@
"proxyUserHelpText": "The username for the proxy server authentication.",
"proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.",
"enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.",
"nameSpaceStrategyHelpText": "The namespace strategy."
"nameSpaceStrategyHelpText": "The namespace strategy.",
"loginInfoUrlHelpText": "URL to login information (plugin and feature tips at login page). If this is omitted, no login information will be displayed."
}
}

View File

@@ -54,6 +54,7 @@ class ConfigForm extends React.Component<Props, State> {
loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true,
namespaceStrategy: "",
loginInfoUrl: "",
_links: {}
},
showNotification: false,
@@ -119,6 +120,7 @@ class ConfigForm extends React.Component<Props, State> {
{noPermissionNotification}
<GeneralSettings
namespaceStrategies={namespaceStrategies}
loginInfoUrl={config.loginInfoUrl}
realmDescription={config.realmDescription}
enableRepositoryArchive={config.enableRepositoryArchive}
disableGroupingGrid={config.disableGroupingGrid}

View File

@@ -7,6 +7,7 @@ import NamespaceStrategySelect from "./NamespaceStrategySelect";
type Props = {
realmDescription: string,
loginInfoUrl: string,
enableRepositoryArchive: boolean,
disableGroupingGrid: boolean,
dateFormat: string,
@@ -27,6 +28,7 @@ class GeneralSettings extends React.Component<Props> {
const {
t,
realmDescription,
loginInfoUrl,
enabledXsrfProtection,
namespaceStrategy,
hasUpdatePermission,
@@ -57,6 +59,15 @@ class GeneralSettings extends React.Component<Props> {
</div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.login-info-url")}
onChange={this.handleLoginInfoUrlChange}
value={loginInfoUrl}
disabled={!hasUpdatePermission}
helpText={t("help.loginInfoUrlHelpText")}
/>
</div>
<div className="column is-half">
<Checkbox
checked={enabledXsrfProtection}
@@ -71,6 +82,10 @@ class GeneralSettings extends React.Component<Props> {
);
}
handleLoginInfoUrlChange = (value: string) => {
this.props.onChange(true, value, "loginInfoUrl");
};
handleRealmDescriptionChange = (value: string) => {
this.props.onChange(true, value, "realmDescription");
};

View File

@@ -0,0 +1,83 @@
//@flow
import * as React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
import { translate } from "react-i18next";
import type { InfoItem } from "./InfoItem";
const styles = {
image: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
width: 160,
height: 160
},
icon: {
color: "#bff1e6"
},
label: {
marginTop: "0.5em"
},
content: {
marginLeft: "1.5em"
},
link: {
display: "block",
marginBottom: "1.5rem"
}
};
type Props = {
type: "plugin" | "feature",
item: InfoItem,
// context props
classes: any,
t: string => string
};
class InfoBox extends React.Component<Props> {
renderBody = () => {
const { item, t } = this.props;
const bodyClasses = classNames("media-content", "content", this.props.classes.content);
const title = item ? item.title : t("login.loading");
const summary = item ? item.summary : t("login.loading");
return (
<div className={bodyClasses}>
<h4 className="has-text-link">{title}</h4>
<p>{summary}</p>
</div>
);
};
render() {
const { item, type, classes, t } = this.props;
const icon = type === "plugin" ? "puzzle-piece" : "star";
return (
<a href={item._links.self.href} className={classes.link}>
<div className="box media">
<figure className="media-left">
<div
className={classNames("image", "box", "has-background-info", "has-text-white", "has-text-weight-bold", classes.image)}>
<i className={classNames("fas", "fa-" + icon, "fa-2x", classes.icon)}/>
<div className={classNames("is-size-4", classes.label)}>{t("login." + type)}</div>
<div className={classNames("is-size-4")}>{t("login.tip")}</div>
</div>
</figure>
{this.renderBody()}
</div>
</a>
);
}
}
export default injectSheet(styles)(translate("commons")(InfoBox));

View File

@@ -0,0 +1,8 @@
// @flow
import type { Link } from "@scm-manager/ui-types";
export type InfoItem = {
title: string,
summary: string,
_links: {[string]: Link}
};

View File

@@ -0,0 +1,120 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { Image, ErrorNotification, InputField, SubmitButton, UnauthorizedError } from "@scm-manager/ui-components";
import classNames from "classnames";
import injectSheet from "react-jss";
const styles = {
avatar: {
marginTop: "-70px",
paddingBottom: "20px"
},
avatarImage: {
border: "1px solid lightgray",
padding: "5px",
background: "#fff",
borderRadius: "50%",
width: "128px",
height: "128px"
},
avatarSpacing: {
marginTop: "5rem"
}
};
type Props = {
error?: Error,
loading: boolean,
loginHandler: (username: string, password: string) => void,
// context props
t: string => string,
classes: any
};
type State = {
username: string,
password: string
};
class LoginForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { username: "", password: "" };
}
handleSubmit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.loginHandler(
this.state.username,
this.state.password
);
}
};
handleUsernameChange = (value: string) => {
this.setState({ username: value });
};
handlePasswordChange = (value: string) => {
this.setState({ password: value });
};
isValid() {
return this.state.username && this.state.password;
}
areCredentialsInvalid() {
const { t, error } = this.props;
if (error instanceof UnauthorizedError) {
return new Error(t("errorNotification.wrongLoginCredentials"));
} else {
return error;
}
}
render() {
const { loading, classes, t } = this.props;
return (
<div className="column is-4 box has-text-centered has-background-white-ter">
<h3 className="title">{t("login.title")}</h3>
<p className="subtitle">{t("login.subtitle")}</p>
<div className={classNames("box", classes.avatarSpacing)}>
<figure className={classes.avatar}>
<Image
className={classes.avatarImage}
src="/images/blib.jpg"
alt={t("login.logo-alt")}
/>
</figure>
<ErrorNotification error={this.areCredentialsInvalid()}/>
<form onSubmit={this.handleSubmit}>
<InputField
placeholder={t("login.username-placeholder")}
autofocus={true}
onChange={this.handleUsernameChange}
/>
<InputField
placeholder={t("login.password-placeholder")}
type="password"
onChange={this.handlePasswordChange}
/>
<SubmitButton
label={t("login.submit")}
fullWidth={true}
loading={loading}
/>
</form>
</div>
</div>
);
}
}
export default injectSheet(styles)(translate("commons")(LoginForm));

View File

@@ -0,0 +1,97 @@
//@flow
import React from "react";
import InfoBox from "./InfoBox";
import type { InfoItem } from "./InfoItem";
import LoginForm from "./LoginForm";
import { Loading } from "@scm-manager/ui-components";
type Props = {
loginInfoLink?: string,
loading?: boolean,
error?: Error,
loginHandler: (username: string, password: string) => void,
};
type LoginInfoResponse = {
plugin?: InfoItem,
feature?: InfoItem
};
type State = {
info?: LoginInfoResponse,
loading?: boolean,
};
class LoginInfo extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: !!props.loginInfoLink
};
}
fetchLoginInfo = (url: string) => {
return fetch(url)
.then(response => response.json())
.then(info => {
this.setState({
info,
loading: false
});
});
};
timeout = (ms: number, promise: Promise<any>) => {
return new Promise<LoginInfoResponse>((resolve, reject) => {
setTimeout(() => {
reject(new Error("timeout during fetch of login info"));
}, ms);
promise.then(resolve, reject);
});
};
componentDidMount() {
const { loginInfoLink } = this.props;
if (!loginInfoLink) {
return;
}
this.timeout(1000, this.fetchLoginInfo(loginInfoLink))
.catch(() => {
this.setState({
loading: false
});
});
}
createInfoPanel = (info: LoginInfoResponse) => (
<div className="column is-7 is-offset-1 is-paddingless">
<InfoBox item={info.feature} type="feature" />
<InfoBox item={info.plugin} type="plugin" />
</div>
);
render() {
const { info, loading } = this.state;
if (loading) {
return <Loading/>;
}
let infoPanel;
if (info) {
infoPanel = this.createInfoPanel(info);
}
return (
<>
<LoginForm {...this.props} />
{infoPanel}
</>
);
}
}
export default LoginInfo;

View File

@@ -1,142 +1,66 @@
//@flow
import React from "react";
import { Redirect, withRouter } from "react-router-dom";
import injectSheet from "react-jss";
import {translate} from "react-i18next";
import {getLoginFailure, isAuthenticated, isLoginPending, login} from "../modules/auth";
import {
login,
isAuthenticated,
isLoginPending,
getLoginFailure
} from "../modules/auth";
import { connect } from "react-redux";
import {ErrorNotification, Image, InputField, SubmitButton, UnauthorizedError} from "@scm-manager/ui-components";
import { getLoginLink, getLoginInfoLink } from "../modules/indexResource";
import LoginInfo from "../components/LoginInfo";
import classNames from "classnames";
import {getLoginLink} from "../modules/indexResource";
import injectSheet from "react-jss";
const styles = {
avatar: {
marginTop: "-70px",
paddingBottom: "20px"
},
avatarImage: {
border: "1px solid lightgray",
padding: "5px",
background: "#fff",
borderRadius: "50%",
width: "128px",
height: "128px"
},
avatarSpacing: {
marginTop: "5rem"
section: {
paddingTop: "2em"
}
};
type Props = {
authenticated: boolean,
loading: boolean,
error: Error,
error?: Error,
link: string,
loginInfoLink?: string,
// dispatcher props
login: (link: string, username: string, password: string) => void,
// context props
t: string => string,
classes: any,
t: string => string,
from: any,
location: any
};
type State = {
username: string,
password: string
class Login extends React.Component<Props> {
handleLogin = (username: string, password: string): void => {
const { link, login } = this.props;
login(link, username, password);
};
class Login extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { username: "", password: "" };
}
handleUsernameChange = (value: string) => {
this.setState({ username: value });
};
handlePasswordChange = (value: string) => {
this.setState({ password: value });
};
handleSubmit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.login(
this.props.link,
this.state.username,
this.state.password
);
}
};
isValid() {
return this.state.username && this.state.password;
}
isInValid() {
return !this.isValid();
}
areCredentialsInvalid() {
const { t, error } = this.props;
if (error instanceof UnauthorizedError) {
return new Error(t("errorNotification.wrongLoginCredentials"));
} else {
return error;
}
}
renderRedirect = () => {
const { from } = this.props.location.state || { from: { pathname: "/" } };
return <Redirect to={from}/>;
};
render() {
const { authenticated, loading, t, classes } = this.props;
const { authenticated, classes, ...restProps } = this.props;
if (authenticated) {
return this.renderRedirect();
}
return (
<section className="hero">
<section className={classNames("hero", classes.section )}>
<div className="hero-body">
<div className="container has-text-centered">
<div className="column is-4 is-offset-4">
<h3 className="title">{t("login.title")}</h3>
<p className="subtitle">{t("login.subtitle")}</p>
<div className={classNames("box", classes.avatarSpacing)}>
<figure className={classes.avatar}>
<Image
className={classes.avatarImage}
src="/images/blib.jpg"
alt={t("login.logo-alt")}
/>
</figure>
<ErrorNotification error={this.areCredentialsInvalid()} />
<form onSubmit={this.handleSubmit}>
<InputField
placeholder={t("login.username-placeholder")}
autofocus={true}
onChange={this.handleUsernameChange}
/>
<InputField
placeholder={t("login.password-placeholder")}
type="password"
onChange={this.handlePasswordChange}
/>
<SubmitButton
label={t("login.submit")}
fullWidth={true}
loading={loading}
/>
</form>
</div>
<div className="container">
<div className="columns is-centered">
<LoginInfo loginHandler={this.handleLogin} {...restProps} />
</div>
</div>
</div>
@@ -150,11 +74,13 @@ const mapStateToProps = state => {
const loading = isLoginPending(state);
const error = getLoginFailure(state);
const link = getLoginLink(state);
const loginInfoLink = getLoginInfoLink(state);
return {
authenticated,
loading,
error,
link
link,
loginInfoLink
};
};
@@ -169,6 +95,6 @@ const StyledLogin = injectSheet(styles)(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(Login))
)(Login)
);
export default withRouter(StyledLogin);

View File

@@ -172,6 +172,10 @@ export function getSvnConfigLink(state: Object) {
return getLink(state, "svnConfig");
}
export function getLoginInfoLink(state: Object) {
return getLink(state, "loginInfo");
}
export function getUserAutoCompleteLink(state: Object): string {
const link = getLinkCollection(state, "autocomplete").find(
i => i.name === "users"

View File

@@ -526,5 +526,9 @@ form .field:not(.is-grouped) {
}
}
// cursor
.has-cursor-pointer {
cursor: pointer;
}
@import "bulma-popover/css/bulma-popover";

View File

@@ -32,6 +32,7 @@ public class ConfigDto extends HalRepresentation {
private long loginAttemptLimitTimeout;
private boolean enabledXsrfProtection;
private String namespaceStrategy;
private String loginInfoUrl;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package

View File

@@ -1,5 +1,6 @@
package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Link;
@@ -7,6 +8,7 @@ import de.otto.edison.hal.Links;
import org.apache.shiro.SecurityUtils;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupPermissions;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.repository.RepositoryRolePermissions;
@@ -23,11 +25,13 @@ public class IndexDtoGenerator extends HalAppenderMapper {
private final ResourceLinks resourceLinks;
private final SCMContextProvider scmContextProvider;
private final ScmConfiguration configuration;
@Inject
public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider) {
public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider, ScmConfiguration configuration) {
this.resourceLinks = resourceLinks;
this.scmContextProvider = scmContextProvider;
this.configuration = configuration;
}
public IndexDto generate() {
@@ -36,6 +40,11 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.self(resourceLinks.index().self());
builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self()));
String loginInfoUrl = configuration.getLoginInfoUrl();
if (!Strings.isNullOrEmpty(loginInfoUrl)) {
builder.single(link("loginInfo", loginInfoUrl));
}
if (SecurityUtils.getSubject().isAuthenticated()) {
builder.single(
link("me", resourceLinks.me().self()),

View File

@@ -50,6 +50,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
assertEquals(40 , config.getLoginAttemptLimitTimeout());
assertTrue(config.isEnabledXsrfProtection());
assertEquals("username", config.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
}
private ConfigDto createDefaultDto() {
@@ -73,6 +74,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setLoginAttemptLimitTimeout(40);
configDto.setEnabledXsrfProtection(true);
configDto.setNamespaceStrategy("username");
configDto.setLoginInfoUrl("https://scm-manager.org/login-info");
return configDto;
}

View File

@@ -3,9 +3,11 @@ package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import org.assertj.core.api.Assertions;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration;
import java.net.URI;
import java.util.Optional;
@@ -19,9 +21,22 @@ public class IndexResourceTest {
@Rule
public final ShiroRule shiroRule = new ShiroRule();
private final SCMContextProvider scmContextProvider = mock(SCMContextProvider.class);
private final IndexDtoGenerator indexDtoGenerator = new IndexDtoGenerator(ResourceLinksMock.createMock(URI.create("/")), scmContextProvider);
private final IndexResource indexResource = new IndexResource(indexDtoGenerator);
private ScmConfiguration configuration;
private SCMContextProvider scmContextProvider;
private IndexResource indexResource;
@Before
public void setUpObjectUnderTest() {
this.configuration = new ScmConfiguration();
this.scmContextProvider = mock(SCMContextProvider.class);
IndexDtoGenerator generator = new IndexDtoGenerator(
ResourceLinksMock.createMock(URI.create("/")),
scmContextProvider,
configuration
);
this.indexResource = new IndexResource(generator);
}
@Test
public void shouldRenderLoginUrlsForUnauthenticatedRequest() {
@@ -30,6 +45,22 @@ public class IndexResourceTest {
Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent);
}
@Test
public void shouldRenderLoginInfoUrl() {
IndexDto index = indexResource.getIndex();
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent();
}
@Test
public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() {
configuration.setLoginInfoUrl("");
IndexDto index = indexResource.getIndex();
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent();
}
@Test
public void shouldRenderSelfLinkForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex();

View File

@@ -80,6 +80,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
assertEquals(2 , dto.getLoginAttemptLimitTimeout());
assertTrue(dto.isEnabledXsrfProtection());
assertEquals("username", dto.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref());
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref());
@@ -118,6 +119,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
config.setLoginAttemptLimitTimeout(2);
config.setEnabledXsrfProtection(true);
config.setNamespaceStrategy("username");
config.setLoginInfoUrl("https://scm-manager.org/login-info");
return config;
}