mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-07 14:05:44 +01:00
fix review findings
This commit is contained in:
@@ -312,10 +312,23 @@ public class ScmConfiguration implements Configuration {
|
||||
return realmDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* since 2.4.0
|
||||
* @return anonymousMode
|
||||
*/
|
||||
public AnonymousMode getAnonymousMode() {
|
||||
return anonymousMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.4.0
|
||||
* @use {@link ScmConfiguration#getAnonymousMode} instead
|
||||
*/
|
||||
@Deprecated
|
||||
public boolean isAnonymousAccessEnabled() {
|
||||
return anonymousMode != AnonymousMode.OFF;
|
||||
}
|
||||
|
||||
public boolean isDisableGroupingGrid() {
|
||||
return disableGroupingGrid;
|
||||
}
|
||||
@@ -361,6 +374,23 @@ public class ScmConfiguration implements Configuration {
|
||||
return skipFailedAuthenticators;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.4.0
|
||||
* @use {@link ScmConfiguration#setAnonymousMode(AnonymousMode)} instead
|
||||
*/
|
||||
@Deprecated
|
||||
public void setAnonymousAccessEnabled(boolean anonymousAccessEnabled) {
|
||||
if (anonymousAccessEnabled) {
|
||||
this.anonymousMode = AnonymousMode.PROTOCOL_ONLY;
|
||||
} else {
|
||||
this.anonymousMode = AnonymousMode.OFF;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* since 2.4.0
|
||||
* @param mode
|
||||
*/
|
||||
public void setAnonymousMode(AnonymousMode mode) {
|
||||
this.anonymousMode = mode;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
|
||||
package sonia.scm.security;
|
||||
|
||||
/**
|
||||
* Available modes for anonymous access
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public enum AnonymousMode {
|
||||
FULL, PROTOCOL_ONLY, OFF
|
||||
}
|
||||
|
||||
@@ -22,42 +22,34 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.web.filter;
|
||||
package sonia.scm.security;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import org.apache.shiro.authc.AuthenticationException;
|
||||
|
||||
public class JwtValidator {
|
||||
/**
|
||||
* This exception is thrown if the session token is expired
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
|
||||
public class TokenExpiredException extends AuthenticationException {
|
||||
|
||||
private JwtValidator() {
|
||||
/**
|
||||
* Constructs a new SessionExpiredException.
|
||||
*
|
||||
* @param message the reason for the exception
|
||||
*/
|
||||
public TokenExpiredException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the jwt token is expired.
|
||||
* Constructs a new SessionExpiredException.
|
||||
*
|
||||
* @return {@code true}if the token is expired
|
||||
* @param message the reason for the exception
|
||||
* @param cause the underlying Throwable that caused this exception to be thrown.
|
||||
*/
|
||||
public static boolean isJwtTokenExpired(String raw) {
|
||||
public TokenExpiredException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
boolean expired = false;
|
||||
|
||||
String[] parts = raw.split("\\.");
|
||||
|
||||
if (parts.length > 1) {
|
||||
Base64.Decoder decoder = Base64.getUrlDecoder();
|
||||
String payload = new String(decoder.decode(parts[1]));
|
||||
String[] splitJwt = payload.split(",");
|
||||
|
||||
for (String entry : splitJwt) {
|
||||
if (entry.contains("\"exp\"")) {
|
||||
long expirationTime = Long.parseLong(entry.replaceAll("[^\\d.]", ""));
|
||||
|
||||
if (Instant.now().isAfter(Instant.ofEpochSecond(expirationTime))) {
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import sonia.scm.SCMContext;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
import sonia.scm.security.AnonymousToken;
|
||||
import sonia.scm.security.BearerToken;
|
||||
import sonia.scm.security.TokenExpiredException;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.util.Util;
|
||||
import sonia.scm.web.WebTokenGenerator;
|
||||
@@ -50,8 +50,6 @@ import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
|
||||
|
||||
/**
|
||||
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
|
||||
* an {@link AuthenticationToken}.
|
||||
@@ -68,7 +66,6 @@ public class AuthenticationFilter extends HttpFilter {
|
||||
* marker for failed authentication
|
||||
*/
|
||||
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
|
||||
private static final String HEADER_AUTHORIZATION = "Authorization";
|
||||
|
||||
private final Set<WebTokenGenerator> tokenGenerators;
|
||||
protected ScmConfiguration configuration;
|
||||
@@ -102,9 +99,7 @@ public class AuthenticationFilter extends HttpFilter {
|
||||
|
||||
AuthenticationToken token = createToken(request);
|
||||
|
||||
if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) {
|
||||
handleUnauthorized(request, response, chain);
|
||||
} else if (token != null) {
|
||||
if (token != null) {
|
||||
logger.trace(
|
||||
"found authentication token on request, start authentication");
|
||||
handleAuthentication(request, response, chain, subject, token);
|
||||
@@ -213,6 +208,13 @@ public class AuthenticationFilter extends HttpFilter {
|
||||
try {
|
||||
subject.login(token);
|
||||
processChain(request, response, chain, subject);
|
||||
} catch (TokenExpiredException ex) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("{} expired", token.getClass(), ex);
|
||||
} else {
|
||||
logger.debug("{} expired", token.getClass());
|
||||
}
|
||||
handleUnauthorized(request, response, chain);
|
||||
} catch (AuthenticationException ex) {
|
||||
logger.warn("authentication failed", ex);
|
||||
handleUnauthorized(request, response, chain);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package sonia.scm.web.filter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
|
||||
|
||||
class JwtValidatorTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnFalseIfNotJwtToken() {
|
||||
String raw = "scmadmin.scmadmin.scmadmin";
|
||||
|
||||
boolean result = isJwtTokenExpired(raw);
|
||||
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidateExpiredJwtToken() {
|
||||
String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs"
|
||||
+ "ImV4cCI6MTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB"
|
||||
+ "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.utZLmzGZr-M6MP19yrd0dgLPkJ0u1xojwHKQi36_QAs";
|
||||
|
||||
boolean result = isJwtTokenExpired(raw);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidateNotExpiredJwtToken() {
|
||||
String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs"
|
||||
+ "ImV4cCI6NTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB"
|
||||
+ "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.cvK4E58734T2PqtEqqhYCInnX_uryUkMhRNX-94riY0";
|
||||
|
||||
boolean result = isJwtTokenExpired(raw);
|
||||
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"baseUrl": "http://localhost:8081/scm"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -23,145 +23,115 @@
|
||||
*/
|
||||
|
||||
describe("With Anonymous mode disabled", () => {
|
||||
it("Should show login page without primary navigation", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
before("Disable anonymous access", () => {
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
setAnonymousMode("OFF");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.contains("Please login to proceed");
|
||||
cy.get("div").not("Login");
|
||||
cy.get("div").not("Repositories");
|
||||
it("Should show login page without primary navigation", () => {
|
||||
cy.byTestId("login-button");
|
||||
cy.containsNotByTestId("div", "primary-navigation-login");
|
||||
cy.containsNotByTestId("div", "primary-navigation-repositories");
|
||||
});
|
||||
it("Should redirect after login", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
|
||||
cy.visit("http://localhost:8081/scm/me");
|
||||
cy.contains("Profile");
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.visit("/me");
|
||||
cy.byTestId("footer-user-profile");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
});
|
||||
|
||||
describe("With Anonymous mode protocol only enabled", () => {
|
||||
it("Should show login page without primary navigation", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
before("Set anonymous mode to protocol only", () => {
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
setAnonymousMode("PROTOCOL_ONLY");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
|
||||
// Give anonymous user permissions
|
||||
cy.get("li")
|
||||
.contains("Users")
|
||||
.click();
|
||||
cy.get("td")
|
||||
.contains("_anonymous")
|
||||
.click();
|
||||
cy.get("a")
|
||||
.contains("Settings")
|
||||
.click();
|
||||
cy.get("li")
|
||||
.contains("Permissions")
|
||||
.click();
|
||||
cy.get("label")
|
||||
.contains("Read all repositories")
|
||||
.click();
|
||||
cy.get("button")
|
||||
.contains("Set permissions")
|
||||
.click();
|
||||
it("Should show login page without primary navigation", () => {
|
||||
cy.visit("/repos/");
|
||||
cy.byTestId("login-button");
|
||||
cy.containsNotByTestId("div", "primary-navigation-login");
|
||||
cy.containsNotByTestId("div", "primary-navigation-repositories");
|
||||
});
|
||||
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.visit("http://localhost:8081/scm/repos/");
|
||||
cy.contains("Please login to proceed");
|
||||
cy.get("div").not("Login");
|
||||
cy.get("div").not("Repositories");
|
||||
after("Disable anonymous access", () => {
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
setAnonymousMode("OFF");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
});
|
||||
|
||||
describe("With Anonymous mode fully enabled", () => {
|
||||
it("Should show repositories overview with Login button in primary navigation", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
before("Set anonymous mode to full", () => {
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
setAnonymousMode("FULL");
|
||||
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.visit("http://localhost:8081/scm/repos/");
|
||||
cy.contains("Overview of available repositories");
|
||||
cy.contains("SCM Anonymous");
|
||||
cy.get("ul").contains("Login");
|
||||
// Give anonymous user permissions
|
||||
cy.byTestId("primary-navigation-users").click();
|
||||
cy.byTestId("_anonymous").click();
|
||||
cy.byTestId("user-settings-link").click();
|
||||
cy.byTestId("user-permissions-link").click();
|
||||
cy.byTestId("read-all-repositories").click();
|
||||
cy.byTestId("set-permissions-button").click();
|
||||
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
|
||||
it("Should show repositories overview with Login button in primary navigation", () => {
|
||||
cy.visit("/repos/");
|
||||
cy.byTestId("repository-overview-filter");
|
||||
cy.byTestId("SCM-Anonymous");
|
||||
cy.byTestId("primary-navigation-login");
|
||||
});
|
||||
it("Should show login page on url", () => {
|
||||
cy.visit("http://localhost:8081/scm/login/");
|
||||
cy.visit("/login/");
|
||||
cy.byTestId("login-button");
|
||||
});
|
||||
it("Should show login page on link click", () => {
|
||||
cy.visit("http://localhost:8081/scm/repos/");
|
||||
cy.contains("Overview of available repositories");
|
||||
cy.contains("Login").click();
|
||||
cy.contains("Please login to proceed");
|
||||
cy.visit("/repos/");
|
||||
cy.byTestId("repository-overview-filter");
|
||||
cy.byTestId("primary-navigation-login").click();
|
||||
cy.byTestId("login-button");
|
||||
});
|
||||
it("Should login and direct to repositories overview", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
|
||||
cy.visit("http://localhost:8081/scm/login");
|
||||
cy.contains("SCM Administrator");
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.visit("/login");
|
||||
cy.byTestId("SCM-Administrator");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
it("Should logout and direct to login page", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
|
||||
cy.visit("http://localhost:8081/scm/repos/");
|
||||
cy.contains("Overview of available repositories");
|
||||
cy.contains("SCM Administrator");
|
||||
cy.contains("Logout").click();
|
||||
cy.contains("Please login to proceed");
|
||||
cy.visit("/repos/");
|
||||
cy.byTestId("repository-overview-filter");
|
||||
cy.byTestId("SCM-Administrator");
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
cy.byTestId("login-button");
|
||||
});
|
||||
it("Anonymous user should not be able to change password", () => {
|
||||
cy.visit("http://localhost:8081/scm/repos/");
|
||||
cy.contains("Profile").click();
|
||||
cy.contains("scm-anonymous@scm-manager.org");
|
||||
cy.get("ul").not("Settings");
|
||||
cy.visit("/repos/");
|
||||
cy.byTestId("footer-user-profile").click();
|
||||
cy.byTestId("SCM-Anonymous");
|
||||
cy.containsNotByTestId("ul", "user-settings-link");
|
||||
cy.get("section").not("Change password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disable anonymous mode after tests", () => {
|
||||
it("Disable anonymous mode after tests", () => {
|
||||
loginUser("scmadmin", "scmadmin");
|
||||
after("Disable anonymous access", () => {
|
||||
cy.login("scmadmin", "scmadmin");
|
||||
setAnonymousMode("OFF");
|
||||
|
||||
cy.get("li")
|
||||
.contains("Logout")
|
||||
.click();
|
||||
cy.byTestId("primary-navigation-logout").click();
|
||||
});
|
||||
});
|
||||
|
||||
const setAnonymousMode = anonymousMode => {
|
||||
cy.get("li")
|
||||
.contains("Administration")
|
||||
.click();
|
||||
cy.get("li")
|
||||
.contains("Settings")
|
||||
.click();
|
||||
cy.get("select")
|
||||
.contains("Disabled")
|
||||
.parent()
|
||||
cy.byTestId("primary-navigation-admin").click();
|
||||
cy.byTestId("admin-settings-link").click();
|
||||
cy.byTestId("anonymous-mode-select")
|
||||
.select(anonymousMode)
|
||||
.should("have.value", anonymousMode);
|
||||
cy.get("button")
|
||||
.contains("Submit")
|
||||
.click();
|
||||
};
|
||||
|
||||
const loginUser = (username, password) => {
|
||||
cy.visit("http://localhost:8081/scm/login");
|
||||
cy.get("div.field.username > div > input").type(username);
|
||||
cy.get("div.field.password > div > input").type(password);
|
||||
cy.get("button")
|
||||
.contains("Login")
|
||||
.click();
|
||||
cy.byTestId("submit-button").click();
|
||||
};
|
||||
|
||||
@@ -46,3 +46,16 @@
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
|
||||
|
||||
const login = (username, password) => {
|
||||
cy.visit( "/login");
|
||||
cy.byTestId("username-input").type(username);
|
||||
cy.byTestId("password-input").type(password);
|
||||
cy.byTestId("login-button").click();
|
||||
};
|
||||
|
||||
Cypress.Commands.add("login", login);
|
||||
Cypress.Commands.add("byTestId", (testId) => cy.get("[data-testid=" + testId + "]"));
|
||||
Cypress.Commands.add("containsNotByTestId", (container, testId) => cy.get(container).not("[data-testid=" + testId + "]"));
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { createAttributesForTesting } from "./devBuild";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
@@ -31,6 +32,7 @@ type Props = {
|
||||
color: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default class Icon extends React.Component<Props> {
|
||||
@@ -40,12 +42,23 @@ export default class Icon extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { title, iconStyle, name, color, className, onClick } = this.props;
|
||||
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
|
||||
if (title) {
|
||||
return (
|
||||
<i onClick={onClick} title={title} className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)} />
|
||||
<i
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <i onClick={onClick} className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)} />;
|
||||
return (
|
||||
<i
|
||||
onClick={onClick}
|
||||
className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,12 @@ type Props = RouteComponentProps & {
|
||||
showCreateButton: boolean;
|
||||
link: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
class OverviewPageActions extends React.Component<Props> {
|
||||
render() {
|
||||
const { history, location, link } = this.props;
|
||||
const { history, location, link, testId } = this.props;
|
||||
return (
|
||||
<>
|
||||
<FilterInput
|
||||
@@ -44,6 +45,7 @@ class OverviewPageActions extends React.Component<Props> {
|
||||
filter={filter => {
|
||||
history.push(`/${link}/?q=${filter}`);
|
||||
}}
|
||||
testId={testId + "-filter"}
|
||||
/>
|
||||
{this.renderCreateButton()}
|
||||
</>
|
||||
|
||||
@@ -39093,15 +39093,6 @@ exports[`Storyshots Layout|Footer Default 1`] = `
|
||||
footer.user.profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
href="/me/settings/password"
|
||||
onClick={[Function]}
|
||||
>
|
||||
profile.changePasswordNavLink
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section
|
||||
@@ -39186,6 +39177,7 @@ exports[`Storyshots Layout|Footer Full 1`] = `
|
||||
<div
|
||||
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
||||
>
|
||||
@@ -39197,6 +39189,7 @@ exports[`Storyshots Layout|Footer Full 1`] = `
|
||||
</span>
|
||||
Trillian McMillian
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
||||
>
|
||||
@@ -39209,15 +39202,6 @@ exports[`Storyshots Layout|Footer Full 1`] = `
|
||||
footer.user.profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
href="/me/settings/password"
|
||||
onClick={[Function]}
|
||||
>
|
||||
profile.changePasswordNavLink
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
@@ -39338,6 +39322,7 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
|
||||
<div
|
||||
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
||||
>
|
||||
@@ -39349,6 +39334,7 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
|
||||
</span>
|
||||
Trillian McMillian
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
||||
>
|
||||
@@ -39361,15 +39347,6 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
|
||||
footer.user.profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
href="/me/settings/password"
|
||||
onClick={[Function]}
|
||||
>
|
||||
profile.changePasswordNavLink
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section
|
||||
@@ -39472,15 +39449,6 @@ exports[`Storyshots Layout|Footer With Plugin Links 1`] = `
|
||||
footer.user.profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
href="/me/settings/password"
|
||||
onClick={[Function]}
|
||||
>
|
||||
profile.changePasswordNavLink
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className=""
|
||||
|
||||
@@ -25,6 +25,7 @@ import React, { MouseEvent, ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { withRouter, RouteComponentProps } from "react-router-dom";
|
||||
import Icon from "../Icon";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
|
||||
export type ButtonProps = {
|
||||
label?: string;
|
||||
@@ -37,6 +38,7 @@ export type ButtonProps = {
|
||||
fullWidth?: boolean;
|
||||
reducedMobile?: boolean;
|
||||
children?: ReactNode;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
type Props = ButtonProps &
|
||||
@@ -73,7 +75,8 @@ class Button extends React.Component<Props> {
|
||||
icon,
|
||||
fullWidth,
|
||||
reducedMobile,
|
||||
children
|
||||
children,
|
||||
testId
|
||||
} = this.props;
|
||||
const loadingClass = loading ? "is-loading" : "";
|
||||
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
|
||||
@@ -86,6 +89,7 @@ class Button extends React.Component<Props> {
|
||||
disabled={disabled}
|
||||
onClick={this.onClick}
|
||||
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
<span className="icon is-medium">
|
||||
<Icon name={icon} color="inherit" />
|
||||
@@ -104,6 +108,7 @@ class Button extends React.Component<Props> {
|
||||
disabled={disabled}
|
||||
onClick={this.onClick}
|
||||
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
{label} {children}
|
||||
</button>
|
||||
|
||||
@@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button";
|
||||
|
||||
type SubmitButtonProps = ButtonProps & {
|
||||
scrollToTop: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||
@@ -34,7 +35,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { action, scrollToTop } = this.props;
|
||||
const { action, scrollToTop, testId } = this.props;
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -48,6 +49,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}}
|
||||
testId={testId ? testId : "submit-button"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
46
scm-ui/ui-components/src/devBuild.ts
Normal file
46
scm-ui/ui-components/src/devBuild.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const isDevBuild = () => (process.env.NODE_ENV === "development")
|
||||
|
||||
export const createAttributesForTesting = (testId?: string) => {
|
||||
if (!testId || !isDevBuild()) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
"data-testid": testId
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceSpacesInTestId = (testId?: string) => {
|
||||
if (!testId) {
|
||||
return testId;
|
||||
}
|
||||
|
||||
let id = testId;
|
||||
while (id.includes(" ")) {
|
||||
id = id.replace(" ", "-");
|
||||
}
|
||||
return id;
|
||||
};
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default class Checkbox extends React.Component<Props> {
|
||||
@@ -59,7 +60,7 @@ export default class Checkbox extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, checked, indeterminate, disabled } = this.props;
|
||||
const { label, checked, indeterminate, disabled, testId } = this.props;
|
||||
return (
|
||||
<div className="field">
|
||||
{this.renderLabelWithHelp()}
|
||||
@@ -70,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
|
||||
but bulma does.
|
||||
// @ts-ignore */}
|
||||
<label className="checkbox" disabled={disabled}>
|
||||
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} />
|
||||
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
|
||||
{label}
|
||||
{this.renderHelp()}
|
||||
</label>
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
filter: (p: string) => void;
|
||||
value?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
@@ -58,9 +60,9 @@ class FilterInput extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { t, testId } = this.props;
|
||||
return (
|
||||
<form className="input-field" onSubmit={this.handleSubmit}>
|
||||
<form className="input-field" onSubmit={this.handleSubmit} {...createAttributesForTesting(testId)}>
|
||||
<div className="control has-icons-left">
|
||||
<FixedHeightInput
|
||||
className="input"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import React, { ChangeEvent, KeyboardEvent } from "react";
|
||||
import classNames from "classnames";
|
||||
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
@@ -39,6 +40,7 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
class InputField extends React.Component<Props> {
|
||||
@@ -80,7 +82,8 @@ class InputField extends React.Component<Props> {
|
||||
disabled,
|
||||
label,
|
||||
helpText,
|
||||
className
|
||||
className,
|
||||
testId
|
||||
} = this.props;
|
||||
const errorView = validationError ? "is-danger" : "";
|
||||
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
|
||||
@@ -99,6 +102,7 @@ class InputField extends React.Component<Props> {
|
||||
onChange={this.handleInput}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
disabled={disabled}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
</div>
|
||||
{helper}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import React, { ChangeEvent } from "react";
|
||||
import classNames from "classnames";
|
||||
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
||||
import {createAttributesForTesting} from "../devBuild";
|
||||
|
||||
export type SelectItem = {
|
||||
value: string;
|
||||
@@ -39,6 +40,7 @@ type Props = {
|
||||
loading?: boolean;
|
||||
helpText?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
class Select extends React.Component<Props> {
|
||||
@@ -57,7 +59,7 @@ class Select extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, value, label, helpText, loading, disabled } = this.props;
|
||||
const { options, value, label, helpText, loading, disabled, testId } = this.props;
|
||||
const loadingClass = loading ? "is-loading" : "";
|
||||
|
||||
return (
|
||||
@@ -71,6 +73,7 @@ class Select extends React.Component<Props> {
|
||||
value={value}
|
||||
onChange={this.handleInput}
|
||||
disabled={disabled}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
{options.map(opt => {
|
||||
return (
|
||||
|
||||
@@ -29,9 +29,10 @@ type Props = {
|
||||
indeterminate?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }) => {
|
||||
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label, testId }) => {
|
||||
let icon;
|
||||
if (indeterminate) {
|
||||
icon = "minus-square";
|
||||
@@ -57,8 +58,11 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }
|
||||
color = "black";
|
||||
}
|
||||
|
||||
return <><Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} />{" "}
|
||||
{label}</>;
|
||||
return (
|
||||
<>
|
||||
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriStateCheckbox;
|
||||
|
||||
@@ -85,6 +85,7 @@ export { default as comparators } from "./comparators";
|
||||
|
||||
export { apiClient } from "./apiclient";
|
||||
export * from "./errors";
|
||||
export { isDevBuild, createAttributesForTesting, replaceSpacesInTestId } from "./devBuild";
|
||||
|
||||
export * from "./avatar";
|
||||
export * from "./buttons";
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC } from "react";
|
||||
import { Me, Links } from "@scm-manager/ui-types";
|
||||
import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { Links, Me } from "@scm-manager/ui-types";
|
||||
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
|
||||
import { AvatarImage } from "../avatar";
|
||||
import NavLink from "../navigation/NavLink";
|
||||
import FooterSection from "./FooterSection";
|
||||
@@ -31,6 +31,7 @@ import styled from "styled-components";
|
||||
import { EXTENSION_POINT } from "../avatar/Avatar";
|
||||
import ExternalNavLink from "../navigation/ExternalNavLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createAttributesForTesting, replaceSpacesInTestId } from "../devBuild";
|
||||
|
||||
type Props = {
|
||||
me?: Me;
|
||||
@@ -43,11 +44,13 @@ type TitleWithIconsProps = {
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => (
|
||||
const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => {
|
||||
return (
|
||||
<>
|
||||
<i className={`fas fa-${icon} fa-fw`} /> {title}
|
||||
<i className={`fas fa-${icon} fa-fw`} {...createAttributesForTesting(replaceSpacesInTestId(title))} /> {title}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TitleWithAvatarProps = {
|
||||
me: Me;
|
||||
@@ -66,12 +69,12 @@ const AvatarContainer = styled.span`
|
||||
`;
|
||||
|
||||
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
|
||||
<>
|
||||
<div {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>
|
||||
<AvatarContainer className="image is-rounded">
|
||||
<VCenteredAvatar person={me} representation="rounded" />
|
||||
</AvatarContainer>
|
||||
{me.displayName}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Footer: FC<Props> = ({ me, version, links }) => {
|
||||
@@ -89,24 +92,14 @@ const Footer: FC<Props> = ({ me, version, links }) => {
|
||||
meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />;
|
||||
}
|
||||
|
||||
let meSectionBody = <div />;
|
||||
{
|
||||
if (me.name !== "_anonymous")
|
||||
meSectionBody = (
|
||||
<>
|
||||
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="footer">
|
||||
<section className="section container">
|
||||
<div className="columns is-size-7">
|
||||
<FooterSection title={meSectionTile}>
|
||||
<NavLink to="/me" label={t("footer.user.profile")} />
|
||||
{meSectionBody}
|
||||
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
|
||||
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</FooterSection>
|
||||
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
||||
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />
|
||||
|
||||
@@ -28,15 +28,16 @@ import { RoutingProps } from "./RoutingProps";
|
||||
import { FC } from "react";
|
||||
import useMenuContext from "./MenuContext";
|
||||
import useActiveMatch from "./useActiveMatch";
|
||||
import {createAttributesForTesting} from "../devBuild";
|
||||
|
||||
type Props = RoutingProps & {
|
||||
label: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, className }) => {
|
||||
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, testId }) => {
|
||||
const active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact });
|
||||
|
||||
const context = useMenuContext();
|
||||
@@ -53,7 +54,11 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
|
||||
|
||||
return (
|
||||
<li title={collapsed ? title : undefined}>
|
||||
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "", className)} to={to}>
|
||||
<Link
|
||||
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
|
||||
to={to}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
{showIcon}
|
||||
{collapsed ? null : label}
|
||||
</Link>
|
||||
|
||||
@@ -40,7 +40,7 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
return (to: string, match: string, label: string, linkName: string) => {
|
||||
const link = links[linkName];
|
||||
if (link) {
|
||||
const navigationItem = <PrimaryNavigationLink className={t(label)} to={to} match={match} label={t(label)} key={linkName} />;
|
||||
const navigationItem = <PrimaryNavigationLink testId={label.replace(".", "-")} to={to} match={match} label={t(label)} key={linkName} />;
|
||||
navigationItems.push(navigationItem);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,30 +23,39 @@
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Route, Link } from "react-router-dom";
|
||||
import classNames from "classnames";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
|
||||
type Props = {
|
||||
to: string;
|
||||
label: string;
|
||||
match?: string;
|
||||
activeOnlyWhenExact?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
class PrimaryNavigationLink extends React.Component<Props> {
|
||||
renderLink = (route: any) => {
|
||||
const { to, label, className } = this.props;
|
||||
const { to, label, testId } = this.props;
|
||||
return (
|
||||
<li className={classNames(route.match ? "is-active" : "", className)}>
|
||||
<Link to={to}>{label}</Link>
|
||||
<li className={route.match ? "is-active" : ""}>
|
||||
<Link to={to} {...createAttributesForTesting(testId)}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { to, match, activeOnlyWhenExact } = this.props;
|
||||
const { to, match, activeOnlyWhenExact, testId } = this.props;
|
||||
const path = match ? match : to;
|
||||
return <Route path={path} exact={activeOnlyWhenExact} children={this.renderLink} />;
|
||||
return (
|
||||
<Route
|
||||
path={path}
|
||||
exact={activeOnlyWhenExact}
|
||||
children={this.renderLink}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,25 @@ import classNames from "classnames";
|
||||
import useMenuContext from "./MenuContext";
|
||||
import { RoutingProps } from "./RoutingProps";
|
||||
import useActiveMatch from "./useActiveMatch";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
|
||||
type Props = RoutingProps & {
|
||||
label: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, icon, title, label, children, className }) => {
|
||||
const SubNavigation: FC<Props> = ({
|
||||
to,
|
||||
activeOnlyWhenExact,
|
||||
activeWhenMatch,
|
||||
icon,
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
testId
|
||||
}) => {
|
||||
const context = useMenuContext();
|
||||
const collapsed = context.isCollapsed();
|
||||
|
||||
@@ -61,7 +71,11 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, ic
|
||||
|
||||
return (
|
||||
<li title={collapsed ? title : undefined}>
|
||||
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "", className)} to={to}>
|
||||
<Link
|
||||
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
|
||||
to={to}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
<i className={classNames(defaultIcon, "fa-fw")} /> {collapsed ? "" : label}
|
||||
</Link>
|
||||
{childrenList}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type Config = {
|
||||
realmDescription: string;
|
||||
disableGroupingGrid: boolean;
|
||||
dateFormat: string;
|
||||
anonymousAccessEnabled: boolean;
|
||||
anonymousMode: AnonymousMode;
|
||||
baseUrl: string;
|
||||
forceBaseUrl: boolean;
|
||||
|
||||
@@ -122,6 +122,7 @@ class GeneralSettings extends React.Component<Props> {
|
||||
{ label: t("general-settings.anonymousMode.off"), value: "OFF" }
|
||||
]}
|
||||
helpText={t("help.allowAnonymousAccessHelpText")}
|
||||
testId={"anonymous-mode-select"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ class Admin extends React.Component<Props> {
|
||||
icon="fas fa-info-circle"
|
||||
label={t("admin.menu.informationNavLink")}
|
||||
title={t("admin.menu.informationNavLink")}
|
||||
className={t("admin.menu.informationNavLink")}
|
||||
testId="admin-information-link"
|
||||
/>
|
||||
{(availablePluginsLink || installedPluginsLink) && (
|
||||
<SubNavigation
|
||||
@@ -141,13 +141,21 @@ class Admin extends React.Component<Props> {
|
||||
icon="fas fa-puzzle-piece"
|
||||
label={t("plugins.menu.pluginsNavLink")}
|
||||
title={t("plugins.menu.pluginsNavLink")}
|
||||
className={t("plugins.menu.pluginsNavLink")}
|
||||
testId="admin-plugins-link"
|
||||
>
|
||||
{installedPluginsLink && (
|
||||
<NavLink to={`${url}/plugins/installed/`} label={t("plugins.menu.installedNavLink")} className={t("plugins.menu.installedNavLink")}/>
|
||||
<NavLink
|
||||
to={`${url}/plugins/installed/`}
|
||||
label={t("plugins.menu.installedNavLink")}
|
||||
testId="admin-installed-plugins-link"
|
||||
/>
|
||||
)}
|
||||
{availablePluginsLink && (
|
||||
<NavLink to={`${url}/plugins/available/`} label={t("plugins.menu.availableNavLink")} className={t("plugins.menu.availableNavLink")} />
|
||||
<NavLink
|
||||
to={`${url}/plugins/available/`}
|
||||
label={t("plugins.menu.availableNavLink")}
|
||||
testId="admin-available-plugins-link"
|
||||
/>
|
||||
)}
|
||||
</SubNavigation>
|
||||
)}
|
||||
@@ -156,7 +164,7 @@ class Admin extends React.Component<Props> {
|
||||
icon="fas fa-user-shield"
|
||||
label={t("repositoryRole.navLink")}
|
||||
title={t("repositoryRole.navLink")}
|
||||
className={t("repositoryRole.navLink")}
|
||||
testId="admin-repository-role-link"
|
||||
activeWhenMatch={this.matchesRoles}
|
||||
activeOnlyWhenExact={false}
|
||||
/>
|
||||
@@ -165,9 +173,13 @@ class Admin extends React.Component<Props> {
|
||||
to={`${url}/settings/general`}
|
||||
label={t("admin.menu.settingsNavLink")}
|
||||
title={t("admin.menu.settingsNavLink")}
|
||||
className={t("admin.menu.settingsNavLink")}
|
||||
testId="admin-settings-link"
|
||||
>
|
||||
<NavLink to={`${url}/settings/general`} label={t("admin.menu.generalNavLink")} className={t("admin.menu.generalNavLink")}/>
|
||||
<NavLink
|
||||
to={`${url}/settings/general`}
|
||||
label={t("admin.menu.generalNavLink")}
|
||||
testId="admin-settings-general-link"
|
||||
/>
|
||||
<ExtensionPoint name="admin.setting" props={extensionProps} renderAll={true} />
|
||||
</SubNavigation>
|
||||
</SecondaryNavigation>
|
||||
|
||||
@@ -109,18 +109,18 @@ class LoginForm extends React.Component<Props, State> {
|
||||
<ErrorNotification error={this.areCredentialsInvalid()} />
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<InputField
|
||||
className="username"
|
||||
testId="username-input"
|
||||
placeholder={t("login.username-placeholder")}
|
||||
autofocus={true}
|
||||
onChange={this.handleUsernameChange}
|
||||
/>
|
||||
<InputField
|
||||
className="password"
|
||||
testId="password-input"
|
||||
placeholder={t("login.password-placeholder")}
|
||||
type="password"
|
||||
onChange={this.handlePasswordChange}
|
||||
/>
|
||||
<SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} />
|
||||
<SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} testId="login-button" />
|
||||
</form>
|
||||
</TopMarginBox>
|
||||
</div>
|
||||
|
||||
@@ -37,19 +37,14 @@ type Props = RouteComponentProps &
|
||||
logoutLink: string;
|
||||
|
||||
// dispatcher functions
|
||||
logout: (link: string) => void;
|
||||
logout: (link: string, callback: () => void) => void;
|
||||
};
|
||||
|
||||
class Logout extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (this.props.logoutLink) {
|
||||
this.props.logout(this.props.logoutLink);
|
||||
resolve(this.props.history.push("/login"));
|
||||
this.props.logout(this.props.logoutLink, () => this.props.history.push("/login"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -73,7 +68,7 @@ const mapStateToProps = (state: any) => {
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
logout: (link: string) => dispatch(logout(link))
|
||||
logout: (link: string, callback: () => void) => dispatch(logout(link, callback))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
import React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { Me } from "@scm-manager/ui-types";
|
||||
import { AvatarImage, AvatarWrapper, MailLink } from "@scm-manager/ui-components";
|
||||
import {
|
||||
AvatarImage,
|
||||
AvatarWrapper,
|
||||
MailLink,
|
||||
createAttributesForTesting,
|
||||
replaceSpacesInTestId
|
||||
} from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
me: Me;
|
||||
@@ -47,11 +53,11 @@ class ProfileInfo extends React.Component<Props> {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("profile.username")}</th>
|
||||
<td>{me.name}</td>
|
||||
<td {...createAttributesForTesting(replaceSpacesInTestId(me.name))}>{me.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("profile.displayName")}</th>
|
||||
<td>{me.displayName}</td>
|
||||
<td {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>{me.displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("profile.mail")}</th>
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
OverviewPageActions,
|
||||
Page,
|
||||
PageActions,
|
||||
urls,
|
||||
Loading
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getGroupsLink } from "../../modules/indexResource";
|
||||
import {
|
||||
@@ -88,11 +87,6 @@ class Groups extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { groups, loading, error, canAddGroups, t } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
|
||||
{this.renderGroupTable()}
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("auth actions", () => {
|
||||
fetchMock.postOnce("/api/v2/auth/access_token", {
|
||||
body: {
|
||||
cookie: true,
|
||||
grantType: "password",
|
||||
grant_type: "password",
|
||||
username: "tricia",
|
||||
password: "secret123"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
callFetchIndexResources,
|
||||
fetchIndexResources,
|
||||
fetchIndexResourcesPending,
|
||||
fetchIndexResourcesSuccess, getLoginLink
|
||||
fetchIndexResourcesSuccess,
|
||||
getLoginLink
|
||||
} from "./indexResource";
|
||||
import { AnyAction } from "redux";
|
||||
|
||||
@@ -176,7 +177,7 @@ const callFetchMe = (link: string): Promise<Me> => {
|
||||
export const login = (loginLink: string, username: string, password: string) => {
|
||||
const loginData = {
|
||||
cookie: true,
|
||||
grantType: "password",
|
||||
grant_type: "password",
|
||||
username,
|
||||
password
|
||||
};
|
||||
@@ -219,7 +220,7 @@ export const fetchMe = (link: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = (link: string) => {
|
||||
export const logout = (link: string, callback: () => void) => {
|
||||
return function(dispatch: any) {
|
||||
dispatch(logoutPending());
|
||||
return apiClient
|
||||
@@ -247,6 +248,7 @@ export const logout = (link: string) => {
|
||||
dispatch(fetchIndexResources());
|
||||
}
|
||||
})
|
||||
.then(callback)
|
||||
.catch(error => {
|
||||
dispatch(logoutFailure(error));
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ class PermissionCheckbox extends React.Component<Props> {
|
||||
? t("verbs.repository." + name + ".description")
|
||||
: this.translateOrDefault("permissions." + key + ".description", t("permissions.unknown"));
|
||||
|
||||
// @ts-ignore we have to use the label here because cypress gets confused with asterix and dots
|
||||
const testId = label.replaceAll(" ", "-").toLowerCase();
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={name}
|
||||
@@ -54,6 +57,7 @@ class PermissionCheckbox extends React.Component<Props> {
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ class SetPermissions extends React.Component<Props, State> {
|
||||
disabled={!this.state.permissionsChanged}
|
||||
loading={loading}
|
||||
label={t("setPermissions.button")}
|
||||
testId="set-permissions-button"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { Me, RepositoryCollection } from "@scm-manager/ui-types";
|
||||
import { RepositoryCollection } from "@scm-manager/ui-types";
|
||||
import {
|
||||
CreateButton,
|
||||
LinkPaginator,
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
OverviewPageActions,
|
||||
Page,
|
||||
PageActions,
|
||||
urls,
|
||||
Loading
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getRepositoriesLink } from "../../modules/indexResource";
|
||||
import {
|
||||
@@ -45,11 +44,9 @@ import {
|
||||
isFetchReposPending
|
||||
} from "../modules/repos";
|
||||
import RepositoryList from "../components/list";
|
||||
import { fetchMe, isFetchMePending } from "../../modules/auth";
|
||||
|
||||
type Props = WithTranslation &
|
||||
RouteComponentProps & {
|
||||
me: Me;
|
||||
loading: boolean;
|
||||
error: Error;
|
||||
showCreateButton: boolean;
|
||||
@@ -63,15 +60,13 @@ type Props = WithTranslation &
|
||||
|
||||
class Overview extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { me, fetchReposByPage, reposLink, page, location } = this.props;
|
||||
if (me) {
|
||||
const { fetchReposByPage, reposLink, page, location } = this.props;
|
||||
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps: Props) => {
|
||||
const { me, loading, collection, page, reposLink, location, fetchReposByPage } = this.props;
|
||||
if (collection && page && !loading && me) {
|
||||
const { loading, collection, page, reposLink, location, fetchReposByPage } = this.props;
|
||||
if (collection && page && !loading) {
|
||||
const statePage: number = collection.page + 1;
|
||||
if (page !== statePage || prevProps.location.search !== location.search) {
|
||||
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
||||
@@ -82,15 +77,16 @@ class Overview extends React.Component<Props> {
|
||||
render() {
|
||||
const { error, loading, showCreateButton, t } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
|
||||
{this.renderOverview()}
|
||||
<PageActions>
|
||||
<OverviewPageActions showCreateButton={showCreateButton} link="repos" label={t("overview.createButton")} />
|
||||
<OverviewPageActions
|
||||
showCreateButton={showCreateButton}
|
||||
link="repos"
|
||||
label={t("overview.createButton")}
|
||||
testId="repository-overview"
|
||||
/>
|
||||
</PageActions>
|
||||
</Page>
|
||||
);
|
||||
@@ -134,15 +130,13 @@ class Overview extends React.Component<Props> {
|
||||
|
||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||
const { match } = ownProps;
|
||||
const me = fetchMe(state);
|
||||
const collection = getRepositoryCollection(state);
|
||||
const loading = isFetchReposPending(state) || isFetchMePending(state);
|
||||
const loading = isFetchReposPending(state);
|
||||
const error = getFetchReposFailure(state);
|
||||
const page = urls.getPageFromMatch(match);
|
||||
const showCreateButton = isAbleToCreateRepos(state);
|
||||
const reposLink = getRepositoriesLink(state);
|
||||
return {
|
||||
me,
|
||||
collection,
|
||||
loading,
|
||||
error,
|
||||
|
||||
@@ -42,7 +42,7 @@ class EditUserNavLink extends React.Component<Props> {
|
||||
if (!this.isEditable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} className={t("singleUser.menu.generalNavLink")}/>;
|
||||
return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} testId="user-edit-link" />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class ChangePasswordNavLink extends React.Component<Props> {
|
||||
if (!this.hasPermissionToSetPassword()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} className={t("singleUser.menu.setPasswordNavLink")}/>;
|
||||
return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} testId="user-password-link"/>;
|
||||
}
|
||||
|
||||
hasPermissionToSetPassword = () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
|
||||
if (!this.hasPermissionToSetPermission()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} className={t("singleUser.menu.setPermissionsNavLink")}/>;
|
||||
return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} testId="user-permissions-link"/>;
|
||||
}
|
||||
|
||||
hasPermissionToSetPermission = () => {
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
import React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { User } from "@scm-manager/ui-types";
|
||||
import { Checkbox, DateFromNow, MailLink } from "@scm-manager/ui-components";
|
||||
import {
|
||||
Checkbox,
|
||||
DateFromNow,
|
||||
MailLink,
|
||||
createAttributesForTesting,
|
||||
replaceSpacesInTestId
|
||||
} from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
user: User;
|
||||
@@ -38,11 +44,11 @@ class Details extends React.Component<Props> {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("user.name")}</th>
|
||||
<td>{user.name}</td>
|
||||
<td {...createAttributesForTesting(replaceSpacesInTestId(user.name))}>{user.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("user.displayName")}</th>
|
||||
<td>{user.displayName}</td>
|
||||
<td {...createAttributesForTesting(replaceSpacesInTestId(user.displayName))}>{user.displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("user.mail")}</th>
|
||||
|
||||
@@ -25,7 +25,7 @@ import React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { User } from "@scm-manager/ui-types";
|
||||
import { Icon } from "@scm-manager/ui-components";
|
||||
import { Icon, createAttributesForTesting } from "@scm-manager/ui-components";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
user: User;
|
||||
@@ -33,7 +33,11 @@ type Props = WithTranslation & {
|
||||
|
||||
class UserRow extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
return (
|
||||
<Link to={to} {...createAttributesForTesting(label)}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -114,13 +114,13 @@ class SingleUser extends React.Component<Props> {
|
||||
icon="fas fa-info-circle"
|
||||
label={t("singleUser.menu.informationNavLink")}
|
||||
title={t("singleUser.menu.informationNavLink")}
|
||||
className={t("singleUser.menu.informationNavLink")}
|
||||
testId="user-information-link"
|
||||
/>
|
||||
<SubNavigation
|
||||
to={`${url}/settings/general`}
|
||||
label={t("singleUser.menu.settingsNavLink")}
|
||||
title={t("singleUser.menu.settingsNavLink")}
|
||||
className={t("singleUser.menu.settingsNavLink")}
|
||||
testId="user-settings-link"
|
||||
>
|
||||
<EditUserNavLink user={user} editUrl={`${url}/settings/general`} />
|
||||
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
OverviewPageActions,
|
||||
Page,
|
||||
PageActions,
|
||||
urls,
|
||||
Loading
|
||||
urls
|
||||
} from "@scm-manager/ui-components";
|
||||
import { getUsersLink } from "../../modules/indexResource";
|
||||
import {
|
||||
@@ -89,10 +88,6 @@ class Users extends React.Component<Props> {
|
||||
render() {
|
||||
const { users, loading, error, canAddUsers, t } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
|
||||
{this.renderUserTable()}
|
||||
|
||||
@@ -32,8 +32,8 @@ import java.util.List;
|
||||
|
||||
public class AuthenticationRequestDto {
|
||||
|
||||
@FormParam("grantType")
|
||||
@JsonProperty("grantType")
|
||||
@FormParam("grant_type")
|
||||
@JsonProperty("grant_type")
|
||||
private String grantType;
|
||||
|
||||
@FormParam("username")
|
||||
@@ -69,7 +69,7 @@ public class AuthenticationRequestDto {
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
// password is currently the only valid grantType
|
||||
// password is currently the only valid grant_type
|
||||
return "password".equals(grantType) && !Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class ConfigDto extends HalRepresentation {
|
||||
private String realmDescription;
|
||||
private boolean disableGroupingGrid;
|
||||
private String dateFormat;
|
||||
private boolean anonymousAccessEnabled;
|
||||
private AnonymousMode anonymousMode;
|
||||
private String baseUrl;
|
||||
private boolean forceBaseUrl;
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
|
||||
// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
|
||||
@SuppressWarnings("squid:S3306")
|
||||
@@ -33,4 +36,15 @@ import sonia.scm.config.ScmConfiguration;
|
||||
public abstract class ConfigDtoToScmConfigurationMapper {
|
||||
|
||||
public abstract ScmConfiguration map(ConfigDto dto);
|
||||
|
||||
@AfterMapping // Should map anonymous mode from old flag if not send explicit
|
||||
void mapAnonymousMode(@MappingTarget ScmConfiguration config, ConfigDto configDto) {
|
||||
if (configDto.getAnonymousMode() == null) {
|
||||
if (configDto.isAnonymousAccessEnabled()) {
|
||||
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
||||
} else {
|
||||
config.setAnonymousMode(AnonymousMode.OFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.group.Group;
|
||||
import sonia.scm.group.GroupManager;
|
||||
import sonia.scm.group.GroupPermissions;
|
||||
|
||||
@@ -123,9 +123,17 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
}
|
||||
|
||||
private boolean shouldAppendSubjectRelatedLinks() {
|
||||
return (SecurityUtils.getSubject().isAuthenticated()
|
||||
&& !Authentications.isAuthenticatedSubjectAnonymous())
|
||||
|| (Authentications.isAuthenticatedSubjectAnonymous()
|
||||
&& configuration.getAnonymousMode() == AnonymousMode.FULL);
|
||||
return isAuthenticatedSubjectNotAnonymous()
|
||||
|| isAuthenticatedSubjectAllowedToBeAnonymous();
|
||||
}
|
||||
|
||||
private boolean isAuthenticatedSubjectAllowedToBeAnonymous() {
|
||||
return Authentications.isAuthenticatedSubjectAnonymous()
|
||||
&& configuration.getAnonymousMode() == AnonymousMode.FULL;
|
||||
}
|
||||
|
||||
private boolean isAuthenticatedSubjectNotAnonymous() {
|
||||
return SecurityUtils.getSubject().isAuthenticated()
|
||||
&& !Authentications.isAuthenticatedSubjectAnonymous();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.subject.PrincipalCollection;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
@@ -83,7 +82,6 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
|
||||
private MeDto createDto(User user) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
||||
if (isNotAnonymous(user)) {
|
||||
if (UserPermissions.delete(user).isPermitted()) {
|
||||
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
|
||||
}
|
||||
@@ -93,15 +91,10 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
}
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
||||
|
||||
return new MeDto(linksBuilder.build(), embeddedBuilder.build());
|
||||
}
|
||||
|
||||
private boolean isNotAnonymous(User user) {
|
||||
return !Authentications.isSubjectAnonymous(user.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ package sonia.scm.api.v2.resources;
|
||||
import de.otto.edison.hal.Links;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.Named;
|
||||
import sonia.scm.config.ConfigurationPermissions;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
@@ -44,6 +47,15 @@ public abstract class ScmConfigurationToConfigDtoMapper extends BaseMapper<ScmCo
|
||||
@Inject
|
||||
private ResourceLinks resourceLinks;
|
||||
|
||||
@Mapping(target = "anonymousAccessEnabled", source = "anonymousMode", qualifiedByName = "mapAnonymousAccess")
|
||||
@Mapping(target = "attributes", ignore = true)
|
||||
public abstract ConfigDto map(ScmConfiguration scmConfiguration);
|
||||
|
||||
@Named("mapAnonymousAccess")
|
||||
boolean mapAnonymousAccess(AnonymousMode anonymousMode) {
|
||||
return anonymousMode != AnonymousMode.OFF;
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self());
|
||||
|
||||
@@ -30,7 +30,6 @@ import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.apache.shiro.authc.credential.PasswordService;
|
||||
import sonia.scm.group.GroupPermissions;
|
||||
import sonia.scm.search.SearchRequest;
|
||||
import sonia.scm.search.SearchUtil;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
@@ -92,6 +92,7 @@ public class BearerRealm extends AuthenticatingRealm
|
||||
checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class);
|
||||
|
||||
BearerToken bt = (BearerToken) token;
|
||||
|
||||
AccessToken accessToken = tokenResolver.resolve(bt);
|
||||
|
||||
return helper.authenticationInfoBuilder(accessToken.getSubject())
|
||||
|
||||
@@ -25,15 +25,17 @@
|
||||
package sonia.scm.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import java.util.Set;
|
||||
import javax.inject.Inject;
|
||||
import org.apache.shiro.authc.AuthenticationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.plugin.Extension;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Jwt implementation of {@link AccessTokenResolver}.
|
||||
*
|
||||
@@ -71,6 +73,8 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
|
||||
validate(token);
|
||||
|
||||
return token;
|
||||
} catch (ExpiredJwtException ex) {
|
||||
throw new TokenExpiredException("The jwt token has been expired", ex);
|
||||
} catch (JwtException ex) {
|
||||
throw new AuthenticationException("signature is invalid", ex);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import lombok.NoArgsConstructor;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.migration.UpdateStep;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
import sonia.scm.store.ConfigurationStore;
|
||||
import sonia.scm.store.ConfigurationStoreFactory;
|
||||
@@ -41,11 +42,12 @@ import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlRootElement;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static sonia.scm.version.Version.parse;
|
||||
|
||||
@Extension
|
||||
public class AnonymousModeUpdateStep implements UpdateStep {
|
||||
|
||||
private final SCMContextProvider contextProvider;
|
||||
@@ -62,12 +64,14 @@ public class AnonymousModeUpdateStep implements UpdateStep {
|
||||
Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION);
|
||||
|
||||
if (configFile.toFile().exists()) {
|
||||
PreUpdateScmConfiguration oldConfig = getPreUpdateScmConfigurationFromOldConfig(configFile);
|
||||
ScmConfiguration config = configStore.get();
|
||||
if (getPreUpdateScmConfigurationFromOldConfig(configFile).isAnonymousAccessEnabled()) {
|
||||
if (oldConfig.isAnonymousAccessEnabled()) {
|
||||
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
||||
} else {
|
||||
config.setAnonymousMode(AnonymousMode.OFF);
|
||||
}
|
||||
configStore.set(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ public class AnonymousModeUpdateStep implements UpdateStep {
|
||||
}
|
||||
|
||||
private Path determineConfigDirectory() {
|
||||
return new File(contextProvider.getBaseDirectory(), StoreConstants.CONFIG_DIRECTORY_NAME).toPath();
|
||||
return contextProvider.resolve(Paths.get(StoreConstants.CONFIG_DIRECTORY_NAME));
|
||||
}
|
||||
|
||||
@XmlRootElement(name = "scm-config")
|
||||
|
||||
@@ -81,37 +81,37 @@ public class AuthenticationResourceTest {
|
||||
|
||||
private static final String AUTH_JSON_TRILLIAN = "{\n" +
|
||||
"\t\"cookie\": true,\n" +
|
||||
"\t\"grantType\": \"password\",\n" +
|
||||
"\t\"grant_type\": \"password\",\n" +
|
||||
"\t\"username\": \"trillian\",\n" +
|
||||
"\t\"password\": \"secret\"\n" +
|
||||
"}";
|
||||
|
||||
private static final String AUTH_FORMENCODED_TRILLIAN = "cookie=true&grantType=password&username=trillian&password=secret";
|
||||
private static final String AUTH_FORMENCODED_TRILLIAN = "cookie=true&grant_type=password&username=trillian&password=secret";
|
||||
|
||||
private static final String AUTH_JSON_TRILLIAN_WRONG_PW = "{\n" +
|
||||
"\t\"cookie\": true,\n" +
|
||||
"\t\"grantType\": \"password\",\n" +
|
||||
"\t\"grant_type\": \"password\",\n" +
|
||||
"\t\"username\": \"trillian\",\n" +
|
||||
"\t\"password\": \"justWrong\"\n" +
|
||||
"}";
|
||||
|
||||
private static final String AUTH_JSON_NOT_EXISTING_USER = "{\n" +
|
||||
"\t\"cookie\": true,\n" +
|
||||
"\t\"grantType\": \"password\",\n" +
|
||||
"\t\"grant_type\": \"password\",\n" +
|
||||
"\t\"username\": \"iDoNotExist\",\n" +
|
||||
"\t\"password\": \"doesNotMatter\"\n" +
|
||||
"}";
|
||||
|
||||
private static final String AUTH_JSON_WITHOUT_USERNAME = String.join("\n",
|
||||
"{",
|
||||
"\"grantType\": \"password\",",
|
||||
"\"grant_type\": \"password\",",
|
||||
"\"password\": \"tricia123\"",
|
||||
"}"
|
||||
);
|
||||
|
||||
private static final String AUTH_JSON_WITHOUT_PASSWORD = String.join("\n",
|
||||
"{",
|
||||
"\"grantType\": \"password\",",
|
||||
"\"grant_type\": \"password\",",
|
||||
"\"username\": \"trillian\"",
|
||||
"}"
|
||||
);
|
||||
@@ -125,7 +125,7 @@ public class AuthenticationResourceTest {
|
||||
|
||||
private static final String AUTH_JSON_WITH_INVALID_GRANT_TYPE = String.join("\n",
|
||||
"{",
|
||||
"\"grantType\": \"el speciale\",",
|
||||
"\"grant_type\": \"el speciale\",",
|
||||
"\"username\": \"trillian\",",
|
||||
"\"password\": \"tricia123\"",
|
||||
"}"
|
||||
|
||||
@@ -64,7 +64,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
||||
assertEquals("realm", config.getRealmDescription());
|
||||
assertTrue(config.isDisableGroupingGrid());
|
||||
assertEquals("yyyy", config.getDateFormat());
|
||||
assertTrue(config.getAnonymousMode() == AnonymousMode.FULL);
|
||||
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
|
||||
assertEquals("baseurl", config.getBaseUrl());
|
||||
assertTrue(config.isForceBaseUrl());
|
||||
assertEquals(41, config.getLoginAttemptLimit());
|
||||
@@ -77,6 +77,21 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
||||
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapAnonymousAccessFieldToAnonymousMode() {
|
||||
ConfigDto dto = createDefaultDto();
|
||||
|
||||
ScmConfiguration config = mapper.map(dto);
|
||||
|
||||
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
|
||||
|
||||
dto.setAnonymousMode(null);
|
||||
dto.setAnonymousAccessEnabled(false);
|
||||
ScmConfiguration config2 = mapper.map(dto);
|
||||
|
||||
assertEquals(AnonymousMode.OFF, config2.getAnonymousMode());
|
||||
}
|
||||
|
||||
private ConfigDto createDefaultDto() {
|
||||
ConfigDto configDto = new ConfigDto();
|
||||
configDto.setProxyPassword("prPw");
|
||||
@@ -87,7 +102,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
||||
configDto.setRealmDescription("realm");
|
||||
configDto.setDisableGroupingGrid(true);
|
||||
configDto.setDateFormat("yyyy");
|
||||
configDto.setAnonymousMode(AnonymousMode.FULL);
|
||||
configDto.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
||||
configDto.setBaseUrl("baseurl");
|
||||
configDto.setForceBaseUrl(true);
|
||||
configDto.setLoginAttemptLimit(41);
|
||||
|
||||
@@ -186,19 +186,6 @@ class MeDtoFactoryTest {
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotGetPasswordLinkForAnonymousUser() {
|
||||
User user = SCMContext.ANONYMOUS;
|
||||
prepareSubject(user);
|
||||
|
||||
when(userManager.isTypeDefault(any())).thenReturn(true);
|
||||
when(UserPermissions.changePassword(user).isPermitted()).thenReturn(true);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldAppendOnlySelfLinkIfAnonymousUser() {
|
||||
User user = SCMContext.ANONYMOUS;
|
||||
|
||||
@@ -95,7 +95,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
||||
assertEquals("description", dto.getRealmDescription());
|
||||
assertTrue(dto.isDisableGroupingGrid());
|
||||
assertEquals("dd", dto.getDateFormat());
|
||||
assertSame(dto.getAnonymousMode(), AnonymousMode.FULL);
|
||||
assertSame(AnonymousMode.FULL, dto.getAnonymousMode());
|
||||
assertEquals("baseurl", dto.getBaseUrl());
|
||||
assertTrue(dto.isForceBaseUrl());
|
||||
assertEquals(1, dto.getLoginAttemptLimit());
|
||||
@@ -123,6 +123,21 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
||||
assertFalse(dto.getLinks().hasLink("update"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapAnonymousAccessField() {
|
||||
ScmConfiguration config = createConfiguration();
|
||||
|
||||
when(subject.hasRole("configuration:write:global")).thenReturn(false);
|
||||
ConfigDto dto = mapper.map(config);
|
||||
|
||||
assertTrue(dto.isAnonymousAccessEnabled());
|
||||
|
||||
config.setAnonymousMode(AnonymousMode.OFF);
|
||||
ConfigDto secondDto = mapper.map(config);
|
||||
|
||||
assertFalse(secondDto.isAnonymousAccessEnabled());
|
||||
}
|
||||
|
||||
private ScmConfiguration createConfiguration() {
|
||||
ScmConfiguration config = new ScmConfiguration();
|
||||
config.setProxyPassword("heartOfGold");
|
||||
|
||||
@@ -130,7 +130,7 @@ public class JwtAccessTokenResolverTest {
|
||||
String compact = createCompactToken("trillian", secureKey, exp, Scope.empty());
|
||||
|
||||
// expect exception
|
||||
expectedException.expect(AuthenticationException.class);
|
||||
expectedException.expect(TokenExpiredException.class);
|
||||
expectedException.expectCause(instanceOf(ExpiredJwtException.class));
|
||||
|
||||
BearerToken bearer = BearerToken.valueOf(compact);
|
||||
|
||||
@@ -42,8 +42,10 @@ import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static sonia.scm.store.InMemoryConfigurationStoreFactory.create;
|
||||
|
||||
@@ -60,8 +62,8 @@ class AnonymousModeUpdateStepTest {
|
||||
|
||||
@BeforeEach
|
||||
void initUpdateStep(@TempDir Path tempDir) throws IOException {
|
||||
when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile());
|
||||
configDir = tempDir.resolve("config");
|
||||
when(contextProvider.resolve(any(Path.class))).thenReturn(tempDir.toAbsolutePath());
|
||||
configDir = tempDir;
|
||||
Files.createDirectories(configDir);
|
||||
InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create();
|
||||
configurationStore = inMemoryConfigurationStoreFactory.get("config", null);
|
||||
|
||||
Reference in New Issue
Block a user