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:
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.config;
|
package sonia.scm.config;
|
||||||
|
|
||||||
|
|
||||||
@@ -312,10 +312,23 @@ public class ScmConfiguration implements Configuration {
|
|||||||
return realmDescription;
|
return realmDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* since 2.4.0
|
||||||
|
* @return anonymousMode
|
||||||
|
*/
|
||||||
public AnonymousMode getAnonymousMode() {
|
public AnonymousMode getAnonymousMode() {
|
||||||
return anonymousMode;
|
return anonymousMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated since 2.4.0
|
||||||
|
* @use {@link ScmConfiguration#getAnonymousMode} instead
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public boolean isAnonymousAccessEnabled() {
|
||||||
|
return anonymousMode != AnonymousMode.OFF;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isDisableGroupingGrid() {
|
public boolean isDisableGroupingGrid() {
|
||||||
return disableGroupingGrid;
|
return disableGroupingGrid;
|
||||||
}
|
}
|
||||||
@@ -361,6 +374,23 @@ public class ScmConfiguration implements Configuration {
|
|||||||
return skipFailedAuthenticators;
|
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) {
|
public void setAnonymousMode(AnonymousMode mode) {
|
||||||
this.anonymousMode = mode;
|
this.anonymousMode = mode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available modes for anonymous access
|
||||||
|
* @since 2.4.0
|
||||||
|
*/
|
||||||
public enum AnonymousMode {
|
public enum AnonymousMode {
|
||||||
FULL, PROTOCOL_ONLY, OFF
|
FULL, PROTOCOL_ONLY, OFF
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,42 +22,34 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.web.filter;
|
package sonia.scm.security;
|
||||||
|
|
||||||
import java.time.Instant;
|
import org.apache.shiro.authc.AuthenticationException;
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
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.config.ScmConfiguration;
|
||||||
import sonia.scm.security.AnonymousMode;
|
import sonia.scm.security.AnonymousMode;
|
||||||
import sonia.scm.security.AnonymousToken;
|
import sonia.scm.security.AnonymousToken;
|
||||||
import sonia.scm.security.BearerToken;
|
import sonia.scm.security.TokenExpiredException;
|
||||||
import sonia.scm.util.HttpUtil;
|
import sonia.scm.util.HttpUtil;
|
||||||
import sonia.scm.util.Util;
|
import sonia.scm.util.Util;
|
||||||
import sonia.scm.web.WebTokenGenerator;
|
import sonia.scm.web.WebTokenGenerator;
|
||||||
@@ -50,8 +50,6 @@ import javax.servlet.http.HttpServletResponse;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
|
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
|
||||||
* an {@link AuthenticationToken}.
|
* an {@link AuthenticationToken}.
|
||||||
@@ -62,13 +60,12 @@ import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
|
|||||||
@Singleton
|
@Singleton
|
||||||
public class AuthenticationFilter extends HttpFilter {
|
public class AuthenticationFilter extends HttpFilter {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
|
private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* marker for failed authentication
|
* marker for failed authentication
|
||||||
*/
|
*/
|
||||||
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
|
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
|
||||||
private static final String HEADER_AUTHORIZATION = "Authorization";
|
|
||||||
|
|
||||||
private final Set<WebTokenGenerator> tokenGenerators;
|
private final Set<WebTokenGenerator> tokenGenerators;
|
||||||
protected ScmConfiguration configuration;
|
protected ScmConfiguration configuration;
|
||||||
@@ -102,9 +99,7 @@ public class AuthenticationFilter extends HttpFilter {
|
|||||||
|
|
||||||
AuthenticationToken token = createToken(request);
|
AuthenticationToken token = createToken(request);
|
||||||
|
|
||||||
if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) {
|
if (token != null) {
|
||||||
handleUnauthorized(request, response, chain);
|
|
||||||
} else if (token != null) {
|
|
||||||
logger.trace(
|
logger.trace(
|
||||||
"found authentication token on request, start authentication");
|
"found authentication token on request, start authentication");
|
||||||
handleAuthentication(request, response, chain, subject, token);
|
handleAuthentication(request, response, chain, subject, token);
|
||||||
@@ -213,6 +208,13 @@ public class AuthenticationFilter extends HttpFilter {
|
|||||||
try {
|
try {
|
||||||
subject.login(token);
|
subject.login(token);
|
||||||
processChain(request, response, chain, subject);
|
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) {
|
} catch (AuthenticationException ex) {
|
||||||
logger.warn("authentication failed", ex);
|
logger.warn("authentication failed", ex);
|
||||||
handleUnauthorized(request, response, chain);
|
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", () => {
|
describe("With Anonymous mode disabled", () => {
|
||||||
it("Should show login page without primary navigation", () => {
|
before("Disable anonymous access", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
setAnonymousMode("OFF");
|
setAnonymousMode("OFF");
|
||||||
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
|
});
|
||||||
|
|
||||||
cy.get("li")
|
it("Should show login page without primary navigation", () => {
|
||||||
.contains("Logout")
|
cy.byTestId("login-button");
|
||||||
.click();
|
cy.containsNotByTestId("div", "primary-navigation-login");
|
||||||
cy.contains("Please login to proceed");
|
cy.containsNotByTestId("div", "primary-navigation-repositories");
|
||||||
cy.get("div").not("Login");
|
|
||||||
cy.get("div").not("Repositories");
|
|
||||||
});
|
});
|
||||||
it("Should redirect after login", () => {
|
it("Should redirect after login", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
|
|
||||||
cy.visit("http://localhost:8081/scm/me");
|
cy.visit("/me");
|
||||||
cy.contains("Profile");
|
cy.byTestId("footer-user-profile");
|
||||||
cy.get("li")
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
.contains("Logout")
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("With Anonymous mode protocol only enabled", () => {
|
describe("With Anonymous mode protocol only enabled", () => {
|
||||||
it("Should show login page without primary navigation", () => {
|
before("Set anonymous mode to protocol only", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
setAnonymousMode("PROTOCOL_ONLY");
|
setAnonymousMode("PROTOCOL_ONLY");
|
||||||
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
|
});
|
||||||
|
|
||||||
// Give anonymous user permissions
|
it("Should show login page without primary navigation", () => {
|
||||||
cy.get("li")
|
cy.visit("/repos/");
|
||||||
.contains("Users")
|
cy.byTestId("login-button");
|
||||||
.click();
|
cy.containsNotByTestId("div", "primary-navigation-login");
|
||||||
cy.get("td")
|
cy.containsNotByTestId("div", "primary-navigation-repositories");
|
||||||
.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();
|
|
||||||
|
|
||||||
cy.get("li")
|
after("Disable anonymous access", () => {
|
||||||
.contains("Logout")
|
cy.login("scmadmin", "scmadmin");
|
||||||
.click();
|
setAnonymousMode("OFF");
|
||||||
cy.visit("http://localhost:8081/scm/repos/");
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
cy.contains("Please login to proceed");
|
|
||||||
cy.get("div").not("Login");
|
|
||||||
cy.get("div").not("Repositories");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("With Anonymous mode fully enabled", () => {
|
describe("With Anonymous mode fully enabled", () => {
|
||||||
it("Should show repositories overview with Login button in primary navigation", () => {
|
before("Set anonymous mode to full", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
setAnonymousMode("FULL");
|
setAnonymousMode("FULL");
|
||||||
|
|
||||||
cy.get("li")
|
// Give anonymous user permissions
|
||||||
.contains("Logout")
|
cy.byTestId("primary-navigation-users").click();
|
||||||
.click();
|
cy.byTestId("_anonymous").click();
|
||||||
cy.visit("http://localhost:8081/scm/repos/");
|
cy.byTestId("user-settings-link").click();
|
||||||
cy.contains("Overview of available repositories");
|
cy.byTestId("user-permissions-link").click();
|
||||||
cy.contains("SCM Anonymous");
|
cy.byTestId("read-all-repositories").click();
|
||||||
cy.get("ul").contains("Login");
|
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", () => {
|
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", () => {
|
it("Should show login page on link click", () => {
|
||||||
cy.visit("http://localhost:8081/scm/repos/");
|
cy.visit("/repos/");
|
||||||
cy.contains("Overview of available repositories");
|
cy.byTestId("repository-overview-filter");
|
||||||
cy.contains("Login").click();
|
cy.byTestId("primary-navigation-login").click();
|
||||||
cy.contains("Please login to proceed");
|
cy.byTestId("login-button");
|
||||||
});
|
});
|
||||||
it("Should login and direct to repositories overview", () => {
|
it("Should login and direct to repositories overview", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
|
|
||||||
cy.visit("http://localhost:8081/scm/login");
|
cy.visit("/login");
|
||||||
cy.contains("SCM Administrator");
|
cy.byTestId("SCM-Administrator");
|
||||||
cy.get("li")
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
.contains("Logout")
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
it("Should logout and direct to login page", () => {
|
it("Should logout and direct to login page", () => {
|
||||||
loginUser("scmadmin", "scmadmin");
|
cy.login("scmadmin", "scmadmin");
|
||||||
|
|
||||||
cy.visit("http://localhost:8081/scm/repos/");
|
cy.visit("/repos/");
|
||||||
cy.contains("Overview of available repositories");
|
cy.byTestId("repository-overview-filter");
|
||||||
cy.contains("SCM Administrator");
|
cy.byTestId("SCM-Administrator");
|
||||||
cy.contains("Logout").click();
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
cy.contains("Please login to proceed");
|
cy.byTestId("login-button");
|
||||||
});
|
});
|
||||||
it("Anonymous user should not be able to change password", () => {
|
it("Anonymous user should not be able to change password", () => {
|
||||||
cy.visit("http://localhost:8081/scm/repos/");
|
cy.visit("/repos/");
|
||||||
cy.contains("Profile").click();
|
cy.byTestId("footer-user-profile").click();
|
||||||
cy.contains("scm-anonymous@scm-manager.org");
|
cy.byTestId("SCM-Anonymous");
|
||||||
cy.get("ul").not("Settings");
|
cy.containsNotByTestId("ul", "user-settings-link");
|
||||||
cy.get("section").not("Change password");
|
cy.get("section").not("Change password");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("Disable anonymous mode after tests", () => {
|
after("Disable anonymous access", () => {
|
||||||
it("Disable anonymous mode after tests", () => {
|
cy.login("scmadmin", "scmadmin");
|
||||||
loginUser("scmadmin", "scmadmin");
|
|
||||||
setAnonymousMode("OFF");
|
setAnonymousMode("OFF");
|
||||||
|
cy.byTestId("primary-navigation-logout").click();
|
||||||
cy.get("li")
|
|
||||||
.contains("Logout")
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const setAnonymousMode = anonymousMode => {
|
const setAnonymousMode = anonymousMode => {
|
||||||
cy.get("li")
|
cy.byTestId("primary-navigation-admin").click();
|
||||||
.contains("Administration")
|
cy.byTestId("admin-settings-link").click();
|
||||||
.click();
|
cy.byTestId("anonymous-mode-select")
|
||||||
cy.get("li")
|
|
||||||
.contains("Settings")
|
|
||||||
.click();
|
|
||||||
cy.get("select")
|
|
||||||
.contains("Disabled")
|
|
||||||
.parent()
|
|
||||||
.select(anonymousMode)
|
.select(anonymousMode)
|
||||||
.should("have.value", anonymousMode);
|
.should("have.value", anonymousMode);
|
||||||
cy.get("button")
|
cy.byTestId("submit-button").click();
|
||||||
.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();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,3 +46,16 @@
|
|||||||
//
|
//
|
||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
// 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 React from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { createAttributesForTesting } from "./devBuild";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -31,6 +32,7 @@ type Props = {
|
|||||||
color: string;
|
color: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class Icon extends React.Component<Props> {
|
export default class Icon extends React.Component<Props> {
|
||||||
@@ -40,12 +42,23 @@ export default class Icon extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { title, iconStyle, name, color, className, onClick } = this.props;
|
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
|
||||||
if (title) {
|
if (title) {
|
||||||
return (
|
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;
|
showCreateButton: boolean;
|
||||||
link: string;
|
link: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class OverviewPageActions extends React.Component<Props> {
|
class OverviewPageActions extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { history, location, link } = this.props;
|
const { history, location, link, testId } = this.props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilterInput
|
<FilterInput
|
||||||
@@ -44,6 +45,7 @@ class OverviewPageActions extends React.Component<Props> {
|
|||||||
filter={filter => {
|
filter={filter => {
|
||||||
history.push(`/${link}/?q=${filter}`);
|
history.push(`/${link}/?q=${filter}`);
|
||||||
}}
|
}}
|
||||||
|
testId={testId + "-filter"}
|
||||||
/>
|
/>
|
||||||
{this.renderCreateButton()}
|
{this.renderCreateButton()}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39093,15 +39093,6 @@ exports[`Storyshots Layout|Footer Default 1`] = `
|
|||||||
footer.user.profile
|
footer.user.profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className=""
|
|
||||||
href="/me/settings/password"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
profile.changePasswordNavLink
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
@@ -39186,16 +39177,18 @@ exports[`Storyshots Layout|Footer Full 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
||||||
>
|
>
|
||||||
<span
|
<div>
|
||||||
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
<span
|
||||||
>
|
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
||||||
<img
|
>
|
||||||
alt="trillian"
|
<img
|
||||||
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
|
alt="trillian"
|
||||||
src="test-file-stub"
|
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
|
||||||
/>
|
src="test-file-stub"
|
||||||
</span>
|
/>
|
||||||
Trillian McMillian
|
</span>
|
||||||
|
Trillian McMillian
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
||||||
@@ -39209,15 +39202,6 @@ exports[`Storyshots Layout|Footer Full 1`] = `
|
|||||||
footer.user.profile
|
footer.user.profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className=""
|
|
||||||
href="/me/settings/password"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
profile.changePasswordNavLink
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
className=""
|
className=""
|
||||||
@@ -39338,16 +39322,18 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
className="FooterSection__Title-lx0ikb-0 gUQuRF"
|
||||||
>
|
>
|
||||||
<span
|
<div>
|
||||||
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
<span
|
||||||
>
|
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
|
||||||
<img
|
>
|
||||||
alt="trillian"
|
<img
|
||||||
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
|
alt="trillian"
|
||||||
src="test-file-stub"
|
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
|
||||||
/>
|
src="test-file-stub"
|
||||||
</span>
|
/>
|
||||||
Trillian McMillian
|
</span>
|
||||||
|
Trillian McMillian
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
className="FooterSection__Menu-lx0ikb-1 idmusL"
|
||||||
@@ -39361,15 +39347,6 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
|
|||||||
footer.user.profile
|
footer.user.profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className=""
|
|
||||||
href="/me/settings/password"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
profile.changePasswordNavLink
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
@@ -39472,15 +39449,6 @@ exports[`Storyshots Layout|Footer With Plugin Links 1`] = `
|
|||||||
footer.user.profile
|
footer.user.profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
className=""
|
|
||||||
href="/me/settings/password"
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
profile.changePasswordNavLink
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
className=""
|
className=""
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import React, { MouseEvent, ReactNode } from "react";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { withRouter, RouteComponentProps } from "react-router-dom";
|
import { withRouter, RouteComponentProps } from "react-router-dom";
|
||||||
import Icon from "../Icon";
|
import Icon from "../Icon";
|
||||||
|
import { createAttributesForTesting } from "../devBuild";
|
||||||
|
|
||||||
export type ButtonProps = {
|
export type ButtonProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -37,6 +38,7 @@ export type ButtonProps = {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
reducedMobile?: boolean;
|
reducedMobile?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = ButtonProps &
|
type Props = ButtonProps &
|
||||||
@@ -73,7 +75,8 @@ class Button extends React.Component<Props> {
|
|||||||
icon,
|
icon,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
reducedMobile,
|
reducedMobile,
|
||||||
children
|
children,
|
||||||
|
testId
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const loadingClass = loading ? "is-loading" : "";
|
const loadingClass = loading ? "is-loading" : "";
|
||||||
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
|
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
|
||||||
@@ -86,6 +89,7 @@ class Button extends React.Component<Props> {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)}
|
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)}
|
||||||
|
{...createAttributesForTesting(testId)}
|
||||||
>
|
>
|
||||||
<span className="icon is-medium">
|
<span className="icon is-medium">
|
||||||
<Icon name={icon} color="inherit" />
|
<Icon name={icon} color="inherit" />
|
||||||
@@ -104,6 +108,7 @@ class Button extends React.Component<Props> {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)}
|
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)}
|
||||||
|
{...createAttributesForTesting(testId)}
|
||||||
>
|
>
|
||||||
{label} {children}
|
{label} {children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button";
|
|||||||
|
|
||||||
type SubmitButtonProps = ButtonProps & {
|
type SubmitButtonProps = ButtonProps & {
|
||||||
scrollToTop: boolean;
|
scrollToTop: boolean;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class SubmitButton extends React.Component<SubmitButtonProps> {
|
class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||||
@@ -34,7 +35,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { action, scrollToTop } = this.props;
|
const { action, scrollToTop, testId } = this.props;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -48,6 +49,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
|
|||||||
window.scrollTo(0, 0);
|
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;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class Checkbox extends React.Component<Props> {
|
export default class Checkbox extends React.Component<Props> {
|
||||||
@@ -59,7 +60,7 @@ export default class Checkbox extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { label, checked, indeterminate, disabled } = this.props;
|
const { label, checked, indeterminate, disabled, testId } = this.props;
|
||||||
return (
|
return (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
{this.renderLabelWithHelp()}
|
{this.renderLabelWithHelp()}
|
||||||
@@ -70,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
|
|||||||
but bulma does.
|
but bulma does.
|
||||||
// @ts-ignore */}
|
// @ts-ignore */}
|
||||||
<label className="checkbox" disabled={disabled}>
|
<label className="checkbox" disabled={disabled}>
|
||||||
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} />
|
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
|
||||||
{label}
|
{label}
|
||||||
{this.renderHelp()}
|
{this.renderHelp()}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -24,10 +24,12 @@
|
|||||||
import React, { ChangeEvent, FormEvent } from "react";
|
import React, { ChangeEvent, FormEvent } from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { createAttributesForTesting } from "../devBuild";
|
||||||
|
|
||||||
type Props = WithTranslation & {
|
type Props = WithTranslation & {
|
||||||
filter: (p: string) => void;
|
filter: (p: string) => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -58,9 +60,9 @@ class FilterInput extends React.Component<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { t } = this.props;
|
const { t, testId } = this.props;
|
||||||
return (
|
return (
|
||||||
<form className="input-field" onSubmit={this.handleSubmit}>
|
<form className="input-field" onSubmit={this.handleSubmit} {...createAttributesForTesting(testId)}>
|
||||||
<div className="control has-icons-left">
|
<div className="control has-icons-left">
|
||||||
<FixedHeightInput
|
<FixedHeightInput
|
||||||
className="input"
|
className="input"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import React, { ChangeEvent, KeyboardEvent } from "react";
|
import React, { ChangeEvent, KeyboardEvent } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
||||||
|
import { createAttributesForTesting } from "../devBuild";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -39,6 +40,7 @@ type Props = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class InputField extends React.Component<Props> {
|
class InputField extends React.Component<Props> {
|
||||||
@@ -80,7 +82,8 @@ class InputField extends React.Component<Props> {
|
|||||||
disabled,
|
disabled,
|
||||||
label,
|
label,
|
||||||
helpText,
|
helpText,
|
||||||
className
|
className,
|
||||||
|
testId
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const errorView = validationError ? "is-danger" : "";
|
const errorView = validationError ? "is-danger" : "";
|
||||||
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
|
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
|
||||||
@@ -99,6 +102,7 @@ class InputField extends React.Component<Props> {
|
|||||||
onChange={this.handleInput}
|
onChange={this.handleInput}
|
||||||
onKeyPress={this.handleKeyPress}
|
onKeyPress={this.handleKeyPress}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
{...createAttributesForTesting(testId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{helper}
|
{helper}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import React, { ChangeEvent } from "react";
|
import React, { ChangeEvent } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
import LabelWithHelpIcon from "./LabelWithHelpIcon";
|
||||||
|
import {createAttributesForTesting} from "../devBuild";
|
||||||
|
|
||||||
export type SelectItem = {
|
export type SelectItem = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -39,6 +40,7 @@ type Props = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Select extends React.Component<Props> {
|
class Select extends React.Component<Props> {
|
||||||
@@ -57,7 +59,7 @@ class Select extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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" : "";
|
const loadingClass = loading ? "is-loading" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +73,7 @@ class Select extends React.Component<Props> {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={this.handleInput}
|
onChange={this.handleInput}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
{...createAttributesForTesting(testId)}
|
||||||
>
|
>
|
||||||
{options.map(opt => {
|
{options.map(opt => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ type Props = {
|
|||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }) => {
|
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label, testId }) => {
|
||||||
let icon;
|
let icon;
|
||||||
if (indeterminate) {
|
if (indeterminate) {
|
||||||
icon = "minus-square";
|
icon = "minus-square";
|
||||||
@@ -57,8 +58,11 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }
|
|||||||
color = "black";
|
color = "black";
|
||||||
}
|
}
|
||||||
|
|
||||||
return <><Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} />{" "}
|
return (
|
||||||
{label}</>;
|
<>
|
||||||
|
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TriStateCheckbox;
|
export default TriStateCheckbox;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export { default as comparators } from "./comparators";
|
|||||||
|
|
||||||
export { apiClient } from "./apiclient";
|
export { apiClient } from "./apiclient";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
|
export { isDevBuild, createAttributesForTesting, replaceSpacesInTestId } from "./devBuild";
|
||||||
|
|
||||||
export * from "./avatar";
|
export * from "./avatar";
|
||||||
export * from "./buttons";
|
export * from "./buttons";
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
import { Me, Links } from "@scm-manager/ui-types";
|
import { Links, Me } from "@scm-manager/ui-types";
|
||||||
import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions";
|
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
|
||||||
import { AvatarImage } from "../avatar";
|
import { AvatarImage } from "../avatar";
|
||||||
import NavLink from "../navigation/NavLink";
|
import NavLink from "../navigation/NavLink";
|
||||||
import FooterSection from "./FooterSection";
|
import FooterSection from "./FooterSection";
|
||||||
@@ -31,6 +31,7 @@ import styled from "styled-components";
|
|||||||
import { EXTENSION_POINT } from "../avatar/Avatar";
|
import { EXTENSION_POINT } from "../avatar/Avatar";
|
||||||
import ExternalNavLink from "../navigation/ExternalNavLink";
|
import ExternalNavLink from "../navigation/ExternalNavLink";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createAttributesForTesting, replaceSpacesInTestId } from "../devBuild";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
me?: Me;
|
me?: Me;
|
||||||
@@ -43,11 +44,13 @@ type TitleWithIconsProps = {
|
|||||||
icon: string;
|
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 = {
|
type TitleWithAvatarProps = {
|
||||||
me: Me;
|
me: Me;
|
||||||
@@ -66,12 +69,12 @@ const AvatarContainer = styled.span`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
|
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
|
||||||
<>
|
<div {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>
|
||||||
<AvatarContainer className="image is-rounded">
|
<AvatarContainer className="image is-rounded">
|
||||||
<VCenteredAvatar person={me} representation="rounded" />
|
<VCenteredAvatar person={me} representation="rounded" />
|
||||||
</AvatarContainer>
|
</AvatarContainer>
|
||||||
{me.displayName}
|
{me.displayName}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Footer: FC<Props> = ({ me, version, links }) => {
|
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" />;
|
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 (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<section className="section container">
|
<section className="section container">
|
||||||
<div className="columns is-size-7">
|
<div className="columns is-size-7">
|
||||||
<FooterSection title={meSectionTile}>
|
<FooterSection title={meSectionTile}>
|
||||||
<NavLink to="/me" label={t("footer.user.profile")} />
|
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
|
||||||
{meSectionBody}
|
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
|
||||||
|
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||||
</FooterSection>
|
</FooterSection>
|
||||||
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
||||||
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />
|
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ import { RoutingProps } from "./RoutingProps";
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import useMenuContext from "./MenuContext";
|
import useMenuContext from "./MenuContext";
|
||||||
import useActiveMatch from "./useActiveMatch";
|
import useActiveMatch from "./useActiveMatch";
|
||||||
|
import {createAttributesForTesting} from "../devBuild";
|
||||||
|
|
||||||
type Props = RoutingProps & {
|
type Props = RoutingProps & {
|
||||||
label: string;
|
label: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: 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 active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact });
|
||||||
|
|
||||||
const context = useMenuContext();
|
const context = useMenuContext();
|
||||||
@@ -53,7 +54,11 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li title={collapsed ? title : undefined}>
|
<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}
|
{showIcon}
|
||||||
{collapsed ? null : label}
|
{collapsed ? null : label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class PrimaryNavigation extends React.Component<Props> {
|
|||||||
return (to: string, match: string, label: string, linkName: string) => {
|
return (to: string, match: string, label: string, linkName: string) => {
|
||||||
const link = links[linkName];
|
const link = links[linkName];
|
||||||
if (link) {
|
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);
|
navigationItems.push(navigationItem);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,30 +23,39 @@
|
|||||||
*/
|
*/
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Route, Link } from "react-router-dom";
|
import { Route, Link } from "react-router-dom";
|
||||||
import classNames from "classnames";
|
import { createAttributesForTesting } from "../devBuild";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
match?: string;
|
match?: string;
|
||||||
activeOnlyWhenExact?: boolean;
|
activeOnlyWhenExact?: boolean;
|
||||||
className?: string;
|
testId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class PrimaryNavigationLink extends React.Component<Props> {
|
class PrimaryNavigationLink extends React.Component<Props> {
|
||||||
renderLink = (route: any) => {
|
renderLink = (route: any) => {
|
||||||
const { to, label, className } = this.props;
|
const { to, label, testId } = this.props;
|
||||||
return (
|
return (
|
||||||
<li className={classNames(route.match ? "is-active" : "", className)}>
|
<li className={route.match ? "is-active" : ""}>
|
||||||
<Link to={to}>{label}</Link>
|
<Link to={to} {...createAttributesForTesting(testId)}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { to, match, activeOnlyWhenExact } = this.props;
|
const { to, match, activeOnlyWhenExact, testId } = this.props;
|
||||||
const path = match ? match : to;
|
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 useMenuContext from "./MenuContext";
|
||||||
import { RoutingProps } from "./RoutingProps";
|
import { RoutingProps } from "./RoutingProps";
|
||||||
import useActiveMatch from "./useActiveMatch";
|
import useActiveMatch from "./useActiveMatch";
|
||||||
|
import { createAttributesForTesting } from "../devBuild";
|
||||||
|
|
||||||
type Props = RoutingProps & {
|
type Props = RoutingProps & {
|
||||||
label: string;
|
label: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: 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 context = useMenuContext();
|
||||||
const collapsed = context.isCollapsed();
|
const collapsed = context.isCollapsed();
|
||||||
|
|
||||||
@@ -61,7 +71,11 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, ic
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li title={collapsed ? title : undefined}>
|
<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}
|
<i className={classNames(defaultIcon, "fa-fw")} /> {collapsed ? "" : label}
|
||||||
</Link>
|
</Link>
|
||||||
{childrenList}
|
{childrenList}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type Config = {
|
|||||||
realmDescription: string;
|
realmDescription: string;
|
||||||
disableGroupingGrid: boolean;
|
disableGroupingGrid: boolean;
|
||||||
dateFormat: string;
|
dateFormat: string;
|
||||||
|
anonymousAccessEnabled: boolean;
|
||||||
anonymousMode: AnonymousMode;
|
anonymousMode: AnonymousMode;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
forceBaseUrl: boolean;
|
forceBaseUrl: boolean;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class GeneralSettings extends React.Component<Props> {
|
|||||||
{ label: t("general-settings.anonymousMode.off"), value: "OFF" }
|
{ label: t("general-settings.anonymousMode.off"), value: "OFF" }
|
||||||
]}
|
]}
|
||||||
helpText={t("help.allowAnonymousAccessHelpText")}
|
helpText={t("help.allowAnonymousAccessHelpText")}
|
||||||
|
testId={"anonymous-mode-select"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class Admin extends React.Component<Props> {
|
|||||||
icon="fas fa-info-circle"
|
icon="fas fa-info-circle"
|
||||||
label={t("admin.menu.informationNavLink")}
|
label={t("admin.menu.informationNavLink")}
|
||||||
title={t("admin.menu.informationNavLink")}
|
title={t("admin.menu.informationNavLink")}
|
||||||
className={t("admin.menu.informationNavLink")}
|
testId="admin-information-link"
|
||||||
/>
|
/>
|
||||||
{(availablePluginsLink || installedPluginsLink) && (
|
{(availablePluginsLink || installedPluginsLink) && (
|
||||||
<SubNavigation
|
<SubNavigation
|
||||||
@@ -141,13 +141,21 @@ class Admin extends React.Component<Props> {
|
|||||||
icon="fas fa-puzzle-piece"
|
icon="fas fa-puzzle-piece"
|
||||||
label={t("plugins.menu.pluginsNavLink")}
|
label={t("plugins.menu.pluginsNavLink")}
|
||||||
title={t("plugins.menu.pluginsNavLink")}
|
title={t("plugins.menu.pluginsNavLink")}
|
||||||
className={t("plugins.menu.pluginsNavLink")}
|
testId="admin-plugins-link"
|
||||||
>
|
>
|
||||||
{installedPluginsLink && (
|
{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 && (
|
{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>
|
</SubNavigation>
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +164,7 @@ class Admin extends React.Component<Props> {
|
|||||||
icon="fas fa-user-shield"
|
icon="fas fa-user-shield"
|
||||||
label={t("repositoryRole.navLink")}
|
label={t("repositoryRole.navLink")}
|
||||||
title={t("repositoryRole.navLink")}
|
title={t("repositoryRole.navLink")}
|
||||||
className={t("repositoryRole.navLink")}
|
testId="admin-repository-role-link"
|
||||||
activeWhenMatch={this.matchesRoles}
|
activeWhenMatch={this.matchesRoles}
|
||||||
activeOnlyWhenExact={false}
|
activeOnlyWhenExact={false}
|
||||||
/>
|
/>
|
||||||
@@ -165,9 +173,13 @@ class Admin extends React.Component<Props> {
|
|||||||
to={`${url}/settings/general`}
|
to={`${url}/settings/general`}
|
||||||
label={t("admin.menu.settingsNavLink")}
|
label={t("admin.menu.settingsNavLink")}
|
||||||
title={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} />
|
<ExtensionPoint name="admin.setting" props={extensionProps} renderAll={true} />
|
||||||
</SubNavigation>
|
</SubNavigation>
|
||||||
</SecondaryNavigation>
|
</SecondaryNavigation>
|
||||||
|
|||||||
@@ -109,18 +109,18 @@ class LoginForm extends React.Component<Props, State> {
|
|||||||
<ErrorNotification error={this.areCredentialsInvalid()} />
|
<ErrorNotification error={this.areCredentialsInvalid()} />
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<InputField
|
<InputField
|
||||||
className="username"
|
testId="username-input"
|
||||||
placeholder={t("login.username-placeholder")}
|
placeholder={t("login.username-placeholder")}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
onChange={this.handleUsernameChange}
|
onChange={this.handleUsernameChange}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
className="password"
|
testId="password-input"
|
||||||
placeholder={t("login.password-placeholder")}
|
placeholder={t("login.password-placeholder")}
|
||||||
type="password"
|
type="password"
|
||||||
onChange={this.handlePasswordChange}
|
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>
|
</form>
|
||||||
</TopMarginBox>
|
</TopMarginBox>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,42 +22,37 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import {connect} from "react-redux";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import {WithTranslation, withTranslation} from "react-i18next";
|
||||||
|
|
||||||
import { getLogoutFailure, logout } from "../modules/auth";
|
import {getLogoutFailure, logout} from "../modules/auth";
|
||||||
import { ErrorPage, Loading } from "@scm-manager/ui-components";
|
import {ErrorPage, Loading} from "@scm-manager/ui-components";
|
||||||
import { getLogoutLink } from "../modules/indexResource";
|
import {getLogoutLink} from "../modules/indexResource";
|
||||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
import {RouteComponentProps, withRouter} from "react-router-dom";
|
||||||
import { compose } from "redux";
|
import {compose} from "redux";
|
||||||
|
|
||||||
type Props = RouteComponentProps &
|
type Props = RouteComponentProps &
|
||||||
WithTranslation & {
|
WithTranslation & {
|
||||||
error: Error;
|
error: Error;
|
||||||
logoutLink: string;
|
logoutLink: string;
|
||||||
|
|
||||||
// dispatcher functions
|
// dispatcher functions
|
||||||
logout: (link: string) => void;
|
logout: (link: string, callback: () => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Logout extends React.Component<Props> {
|
class Logout extends React.Component<Props> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
new Promise((resolve, reject) => {
|
if (this.props.logoutLink) {
|
||||||
setTimeout(() => {
|
this.props.logout(this.props.logoutLink, () => this.props.history.push("/login"));
|
||||||
if (this.props.logoutLink) {
|
}
|
||||||
this.props.logout(this.props.logoutLink);
|
|
||||||
resolve(this.props.history.push("/login"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { error, t } = this.props;
|
const {error, t} = this.props;
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorPage title={t("logout.error.title")} subtitle={t("logout.error.subtitle")} error={error} />;
|
return <ErrorPage title={t("logout.error.title")} subtitle={t("logout.error.subtitle")} error={error}/>;
|
||||||
} else {
|
} else {
|
||||||
return <Loading />;
|
return <Loading/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +68,7 @@ const mapStateToProps = (state: any) => {
|
|||||||
|
|
||||||
const mapDispatchToProps = (dispatch: any) => {
|
const mapDispatchToProps = (dispatch: any) => {
|
||||||
return {
|
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 React from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import { Me } from "@scm-manager/ui-types";
|
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 & {
|
type Props = WithTranslation & {
|
||||||
me: Me;
|
me: Me;
|
||||||
@@ -47,11 +53,11 @@ class ProfileInfo extends React.Component<Props> {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("profile.username")}</th>
|
<th>{t("profile.username")}</th>
|
||||||
<td>{me.name}</td>
|
<td {...createAttributesForTesting(replaceSpacesInTestId(me.name))}>{me.name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("profile.displayName")}</th>
|
<th>{t("profile.displayName")}</th>
|
||||||
<td>{me.displayName}</td>
|
<td {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>{me.displayName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("profile.mail")}</th>
|
<th>{t("profile.mail")}</th>
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ import {
|
|||||||
OverviewPageActions,
|
OverviewPageActions,
|
||||||
Page,
|
Page,
|
||||||
PageActions,
|
PageActions,
|
||||||
urls,
|
urls
|
||||||
Loading
|
|
||||||
} from "@scm-manager/ui-components";
|
} from "@scm-manager/ui-components";
|
||||||
import { getGroupsLink } from "../../modules/indexResource";
|
import { getGroupsLink } from "../../modules/indexResource";
|
||||||
import {
|
import {
|
||||||
@@ -88,11 +87,6 @@ class Groups extends React.Component<Props> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { groups, loading, error, canAddGroups, t } = this.props;
|
const { groups, loading, error, canAddGroups, t } = this.props;
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
|
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
|
||||||
{this.renderGroupTable()}
|
{this.renderGroupTable()}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("auth actions", () => {
|
|||||||
fetchMock.postOnce("/api/v2/auth/access_token", {
|
fetchMock.postOnce("/api/v2/auth/access_token", {
|
||||||
body: {
|
body: {
|
||||||
cookie: true,
|
cookie: true,
|
||||||
grantType: "password",
|
grant_type: "password",
|
||||||
username: "tricia",
|
username: "tricia",
|
||||||
password: "secret123"
|
password: "secret123"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import {
|
|||||||
callFetchIndexResources,
|
callFetchIndexResources,
|
||||||
fetchIndexResources,
|
fetchIndexResources,
|
||||||
fetchIndexResourcesPending,
|
fetchIndexResourcesPending,
|
||||||
fetchIndexResourcesSuccess, getLoginLink
|
fetchIndexResourcesSuccess,
|
||||||
|
getLoginLink
|
||||||
} from "./indexResource";
|
} from "./indexResource";
|
||||||
import { AnyAction } from "redux";
|
import { AnyAction } from "redux";
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ const callFetchMe = (link: string): Promise<Me> => {
|
|||||||
export const login = (loginLink: string, username: string, password: string) => {
|
export const login = (loginLink: string, username: string, password: string) => {
|
||||||
const loginData = {
|
const loginData = {
|
||||||
cookie: true,
|
cookie: true,
|
||||||
grantType: "password",
|
grant_type: "password",
|
||||||
username,
|
username,
|
||||||
password
|
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) {
|
return function(dispatch: any) {
|
||||||
dispatch(logoutPending());
|
dispatch(logoutPending());
|
||||||
return apiClient
|
return apiClient
|
||||||
@@ -247,6 +248,7 @@ export const logout = (link: string) => {
|
|||||||
dispatch(fetchIndexResources());
|
dispatch(fetchIndexResources());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.then(callback)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
dispatch(logoutFailure(error));
|
dispatch(logoutFailure(error));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class PermissionCheckbox extends React.Component<Props> {
|
|||||||
? t("verbs.repository." + name + ".description")
|
? t("verbs.repository." + name + ".description")
|
||||||
: this.translateOrDefault("permissions." + key + ".description", t("permissions.unknown"));
|
: 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 (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={name}
|
key={name}
|
||||||
@@ -54,6 +57,7 @@ class PermissionCheckbox extends React.Component<Props> {
|
|||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
testId={testId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class SetPermissions extends React.Component<Props, State> {
|
|||||||
disabled={!this.state.permissionsChanged}
|
disabled={!this.state.permissionsChanged}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
label={t("setPermissions.button")}
|
label={t("setPermissions.button")}
|
||||||
|
testId="set-permissions-button"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import React from "react";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import { Me, RepositoryCollection } from "@scm-manager/ui-types";
|
import { RepositoryCollection } from "@scm-manager/ui-types";
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
LinkPaginator,
|
LinkPaginator,
|
||||||
@@ -33,8 +33,7 @@ import {
|
|||||||
OverviewPageActions,
|
OverviewPageActions,
|
||||||
Page,
|
Page,
|
||||||
PageActions,
|
PageActions,
|
||||||
urls,
|
urls
|
||||||
Loading
|
|
||||||
} from "@scm-manager/ui-components";
|
} from "@scm-manager/ui-components";
|
||||||
import { getRepositoriesLink } from "../../modules/indexResource";
|
import { getRepositoriesLink } from "../../modules/indexResource";
|
||||||
import {
|
import {
|
||||||
@@ -45,11 +44,9 @@ import {
|
|||||||
isFetchReposPending
|
isFetchReposPending
|
||||||
} from "../modules/repos";
|
} from "../modules/repos";
|
||||||
import RepositoryList from "../components/list";
|
import RepositoryList from "../components/list";
|
||||||
import { fetchMe, isFetchMePending } from "../../modules/auth";
|
|
||||||
|
|
||||||
type Props = WithTranslation &
|
type Props = WithTranslation &
|
||||||
RouteComponentProps & {
|
RouteComponentProps & {
|
||||||
me: Me;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error;
|
error: Error;
|
||||||
showCreateButton: boolean;
|
showCreateButton: boolean;
|
||||||
@@ -63,15 +60,13 @@ type Props = WithTranslation &
|
|||||||
|
|
||||||
class Overview extends React.Component<Props> {
|
class Overview extends React.Component<Props> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { me, fetchReposByPage, reposLink, page, location } = this.props;
|
const { fetchReposByPage, reposLink, page, location } = this.props;
|
||||||
if (me) {
|
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
||||||
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate = (prevProps: Props) => {
|
componentDidUpdate = (prevProps: Props) => {
|
||||||
const { me, loading, collection, page, reposLink, location, fetchReposByPage } = this.props;
|
const { loading, collection, page, reposLink, location, fetchReposByPage } = this.props;
|
||||||
if (collection && page && !loading && me) {
|
if (collection && page && !loading) {
|
||||||
const statePage: number = collection.page + 1;
|
const statePage: number = collection.page + 1;
|
||||||
if (page !== statePage || prevProps.location.search !== location.search) {
|
if (page !== statePage || prevProps.location.search !== location.search) {
|
||||||
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
|
||||||
@@ -82,15 +77,16 @@ class Overview extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { error, loading, showCreateButton, t } = this.props;
|
const { error, loading, showCreateButton, t } = this.props;
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
|
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
|
||||||
{this.renderOverview()}
|
{this.renderOverview()}
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<OverviewPageActions showCreateButton={showCreateButton} link="repos" label={t("overview.createButton")} />
|
<OverviewPageActions
|
||||||
|
showCreateButton={showCreateButton}
|
||||||
|
link="repos"
|
||||||
|
label={t("overview.createButton")}
|
||||||
|
testId="repository-overview"
|
||||||
|
/>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
@@ -134,15 +130,13 @@ class Overview extends React.Component<Props> {
|
|||||||
|
|
||||||
const mapStateToProps = (state: any, ownProps: Props) => {
|
const mapStateToProps = (state: any, ownProps: Props) => {
|
||||||
const { match } = ownProps;
|
const { match } = ownProps;
|
||||||
const me = fetchMe(state);
|
|
||||||
const collection = getRepositoryCollection(state);
|
const collection = getRepositoryCollection(state);
|
||||||
const loading = isFetchReposPending(state) || isFetchMePending(state);
|
const loading = isFetchReposPending(state);
|
||||||
const error = getFetchReposFailure(state);
|
const error = getFetchReposFailure(state);
|
||||||
const page = urls.getPageFromMatch(match);
|
const page = urls.getPageFromMatch(match);
|
||||||
const showCreateButton = isAbleToCreateRepos(state);
|
const showCreateButton = isAbleToCreateRepos(state);
|
||||||
const reposLink = getRepositoriesLink(state);
|
const reposLink = getRepositoriesLink(state);
|
||||||
return {
|
return {
|
||||||
me,
|
|
||||||
collection,
|
collection,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class EditUserNavLink extends React.Component<Props> {
|
|||||||
if (!this.isEditable()) {
|
if (!this.isEditable()) {
|
||||||
return null;
|
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()) {
|
if (!this.hasPermissionToSetPassword()) {
|
||||||
return null;
|
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 = () => {
|
hasPermissionToSetPassword = () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
|
|||||||
if (!this.hasPermissionToSetPermission()) {
|
if (!this.hasPermissionToSetPermission()) {
|
||||||
return null;
|
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 = () => {
|
hasPermissionToSetPermission = () => {
|
||||||
|
|||||||
@@ -24,7 +24,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import { User } from "@scm-manager/ui-types";
|
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 & {
|
type Props = WithTranslation & {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -38,11 +44,11 @@ class Details extends React.Component<Props> {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("user.name")}</th>
|
<th>{t("user.name")}</th>
|
||||||
<td>{user.name}</td>
|
<td {...createAttributesForTesting(replaceSpacesInTestId(user.name))}>{user.name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("user.displayName")}</th>
|
<th>{t("user.displayName")}</th>
|
||||||
<td>{user.displayName}</td>
|
<td {...createAttributesForTesting(replaceSpacesInTestId(user.displayName))}>{user.displayName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("user.mail")}</th>
|
<th>{t("user.mail")}</th>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import React from "react";
|
|||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { User } from "@scm-manager/ui-types";
|
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 & {
|
type Props = WithTranslation & {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -33,7 +33,11 @@ type Props = WithTranslation & {
|
|||||||
|
|
||||||
class UserRow extends React.Component<Props> {
|
class UserRow extends React.Component<Props> {
|
||||||
renderLink(to: string, label: string) {
|
renderLink(to: string, label: string) {
|
||||||
return <Link to={to}>{label}</Link>;
|
return (
|
||||||
|
<Link to={to} {...createAttributesForTesting(label)}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -114,13 +114,13 @@ class SingleUser extends React.Component<Props> {
|
|||||||
icon="fas fa-info-circle"
|
icon="fas fa-info-circle"
|
||||||
label={t("singleUser.menu.informationNavLink")}
|
label={t("singleUser.menu.informationNavLink")}
|
||||||
title={t("singleUser.menu.informationNavLink")}
|
title={t("singleUser.menu.informationNavLink")}
|
||||||
className={t("singleUser.menu.informationNavLink")}
|
testId="user-information-link"
|
||||||
/>
|
/>
|
||||||
<SubNavigation
|
<SubNavigation
|
||||||
to={`${url}/settings/general`}
|
to={`${url}/settings/general`}
|
||||||
label={t("singleUser.menu.settingsNavLink")}
|
label={t("singleUser.menu.settingsNavLink")}
|
||||||
title={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`} />
|
<EditUserNavLink user={user} editUrl={`${url}/settings/general`} />
|
||||||
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
|
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ import {
|
|||||||
OverviewPageActions,
|
OverviewPageActions,
|
||||||
Page,
|
Page,
|
||||||
PageActions,
|
PageActions,
|
||||||
urls,
|
urls
|
||||||
Loading
|
|
||||||
} from "@scm-manager/ui-components";
|
} from "@scm-manager/ui-components";
|
||||||
import { getUsersLink } from "../../modules/indexResource";
|
import { getUsersLink } from "../../modules/indexResource";
|
||||||
import {
|
import {
|
||||||
@@ -89,10 +88,6 @@ class Users extends React.Component<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { users, loading, error, canAddUsers, t } = this.props;
|
const { users, loading, error, canAddUsers, t } = this.props;
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
|
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
|
||||||
{this.renderUserTable()}
|
{this.renderUserTable()}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ import java.util.List;
|
|||||||
|
|
||||||
public class AuthenticationRequestDto {
|
public class AuthenticationRequestDto {
|
||||||
|
|
||||||
@FormParam("grantType")
|
@FormParam("grant_type")
|
||||||
@JsonProperty("grantType")
|
@JsonProperty("grant_type")
|
||||||
private String grantType;
|
private String grantType;
|
||||||
|
|
||||||
@FormParam("username")
|
@FormParam("username")
|
||||||
@@ -69,7 +69,7 @@ public class AuthenticationRequestDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isValid() {
|
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);
|
return "password".equals(grantType) && !Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public class ConfigDto extends HalRepresentation {
|
|||||||
private String realmDescription;
|
private String realmDescription;
|
||||||
private boolean disableGroupingGrid;
|
private boolean disableGroupingGrid;
|
||||||
private String dateFormat;
|
private String dateFormat;
|
||||||
|
private boolean anonymousAccessEnabled;
|
||||||
private AnonymousMode anonymousMode;
|
private AnonymousMode anonymousMode;
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
private boolean forceBaseUrl;
|
private boolean forceBaseUrl;
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
|
import org.mapstruct.AfterMapping;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.MappingTarget;
|
||||||
import sonia.scm.config.ScmConfiguration;
|
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.
|
// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
|
||||||
@SuppressWarnings("squid:S3306")
|
@SuppressWarnings("squid:S3306")
|
||||||
@@ -33,4 +36,15 @@ import sonia.scm.config.ScmConfiguration;
|
|||||||
public abstract class ConfigDtoToScmConfigurationMapper {
|
public abstract class ConfigDtoToScmConfigurationMapper {
|
||||||
|
|
||||||
public abstract ScmConfiguration map(ConfigDto dto);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -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.Content;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import org.apache.shiro.SecurityUtils;
|
|
||||||
import sonia.scm.group.Group;
|
import sonia.scm.group.Group;
|
||||||
import sonia.scm.group.GroupManager;
|
import sonia.scm.group.GroupManager;
|
||||||
import sonia.scm.group.GroupPermissions;
|
import sonia.scm.group.GroupPermissions;
|
||||||
|
|||||||
@@ -123,9 +123,17 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldAppendSubjectRelatedLinks() {
|
private boolean shouldAppendSubjectRelatedLinks() {
|
||||||
return (SecurityUtils.getSubject().isAuthenticated()
|
return isAuthenticatedSubjectNotAnonymous()
|
||||||
&& !Authentications.isAuthenticatedSubjectAnonymous())
|
|| isAuthenticatedSubjectAllowedToBeAnonymous();
|
||||||
|| (Authentications.isAuthenticatedSubjectAnonymous()
|
}
|
||||||
&& configuration.getAnonymousMode() == AnonymousMode.FULL);
|
|
||||||
|
private boolean isAuthenticatedSubjectAllowedToBeAnonymous() {
|
||||||
|
return Authentications.isAuthenticatedSubjectAnonymous()
|
||||||
|
&& configuration.getAnonymousMode() == AnonymousMode.FULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAuthenticatedSubjectNotAnonymous() {
|
||||||
|
return SecurityUtils.getSubject().isAuthenticated()
|
||||||
|
&& !Authentications.isAuthenticatedSubjectAnonymous();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import de.otto.edison.hal.Embedded;
|
import de.otto.edison.hal.Embedded;
|
||||||
@@ -30,7 +30,6 @@ import org.apache.shiro.SecurityUtils;
|
|||||||
import org.apache.shiro.subject.PrincipalCollection;
|
import org.apache.shiro.subject.PrincipalCollection;
|
||||||
import org.apache.shiro.subject.Subject;
|
import org.apache.shiro.subject.Subject;
|
||||||
import sonia.scm.group.GroupCollector;
|
import sonia.scm.group.GroupCollector;
|
||||||
import sonia.scm.security.Authentications;
|
|
||||||
import sonia.scm.user.User;
|
import sonia.scm.user.User;
|
||||||
import sonia.scm.user.UserManager;
|
import sonia.scm.user.UserManager;
|
||||||
import sonia.scm.user.UserPermissions;
|
import sonia.scm.user.UserPermissions;
|
||||||
@@ -83,16 +82,14 @@ public class MeDtoFactory extends HalAppenderMapper {
|
|||||||
|
|
||||||
private MeDto createDto(User user) {
|
private MeDto createDto(User user) {
|
||||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
||||||
if (isNotAnonymous(user)) {
|
if (UserPermissions.delete(user).isPermitted()) {
|
||||||
if (UserPermissions.delete(user).isPermitted()) {
|
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
|
||||||
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
|
}
|
||||||
}
|
if (UserPermissions.modify(user).isPermitted()) {
|
||||||
if (UserPermissions.modify(user).isPermitted()) {
|
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
|
||||||
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
|
}
|
||||||
}
|
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||||
@@ -100,8 +97,4 @@ public class MeDtoFactory extends HalAppenderMapper {
|
|||||||
|
|
||||||
return new MeDto(linksBuilder.build(), embeddedBuilder.build());
|
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 de.otto.edison.hal.Links;
|
||||||
import org.mapstruct.AfterMapping;
|
import org.mapstruct.AfterMapping;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
import org.mapstruct.MappingTarget;
|
import org.mapstruct.MappingTarget;
|
||||||
|
import org.mapstruct.Named;
|
||||||
import sonia.scm.config.ConfigurationPermissions;
|
import sonia.scm.config.ConfigurationPermissions;
|
||||||
import sonia.scm.config.ScmConfiguration;
|
import sonia.scm.config.ScmConfiguration;
|
||||||
|
import sonia.scm.security.AnonymousMode;
|
||||||
|
|
||||||
import javax.inject.Inject;
|
import javax.inject.Inject;
|
||||||
|
|
||||||
@@ -44,6 +47,15 @@ public abstract class ScmConfigurationToConfigDtoMapper extends BaseMapper<ScmCo
|
|||||||
@Inject
|
@Inject
|
||||||
private ResourceLinks resourceLinks;
|
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
|
@AfterMapping
|
||||||
void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) {
|
void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) {
|
||||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self());
|
Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self());
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -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.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import org.apache.shiro.authc.credential.PasswordService;
|
import org.apache.shiro.authc.credential.PasswordService;
|
||||||
import sonia.scm.group.GroupPermissions;
|
|
||||||
import sonia.scm.search.SearchRequest;
|
import sonia.scm.search.SearchRequest;
|
||||||
import sonia.scm.search.SearchUtil;
|
import sonia.scm.search.SearchUtil;
|
||||||
import sonia.scm.user.User;
|
import sonia.scm.user.User;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public class BearerRealm extends AuthenticatingRealm
|
|||||||
checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class);
|
checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class);
|
||||||
|
|
||||||
BearerToken bt = (BearerToken) token;
|
BearerToken bt = (BearerToken) token;
|
||||||
|
|
||||||
AccessToken accessToken = tokenResolver.resolve(bt);
|
AccessToken accessToken = tokenResolver.resolve(bt);
|
||||||
|
|
||||||
return helper.authenticationInfoBuilder(accessToken.getSubject())
|
return helper.authenticationInfoBuilder(accessToken.getSubject())
|
||||||
|
|||||||
@@ -21,22 +21,24 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
import io.jsonwebtoken.JwtException;
|
import io.jsonwebtoken.JwtException;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import java.util.Set;
|
|
||||||
import javax.inject.Inject;
|
|
||||||
import org.apache.shiro.authc.AuthenticationException;
|
import org.apache.shiro.authc.AuthenticationException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import sonia.scm.plugin.Extension;
|
import sonia.scm.plugin.Extension;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jwt implementation of {@link AccessTokenResolver}.
|
* Jwt implementation of {@link AccessTokenResolver}.
|
||||||
*
|
*
|
||||||
* @author Sebastian Sdorra
|
* @author Sebastian Sdorra
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
@@ -47,7 +49,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
|
|||||||
* the logger for JwtAccessTokenResolver
|
* the logger for JwtAccessTokenResolver
|
||||||
*/
|
*/
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class);
|
private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class);
|
||||||
|
|
||||||
private final SecureKeyResolver keyResolver;
|
private final SecureKeyResolver keyResolver;
|
||||||
private final Set<AccessTokenValidator> validators;
|
private final Set<AccessTokenValidator> validators;
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
|
|||||||
this.keyResolver = keyResolver;
|
this.keyResolver = keyResolver;
|
||||||
this.validators = validators;
|
this.validators = validators;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public JwtAccessToken resolve(BearerToken bearerToken) {
|
public JwtAccessToken resolve(BearerToken bearerToken) {
|
||||||
try {
|
try {
|
||||||
@@ -71,6 +73,8 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
|
|||||||
validate(token);
|
validate(token);
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
} catch (ExpiredJwtException ex) {
|
||||||
|
throw new TokenExpiredException("The jwt token has been expired", ex);
|
||||||
} catch (JwtException ex) {
|
} catch (JwtException ex) {
|
||||||
throw new AuthenticationException("signature is invalid", ex);
|
throw new AuthenticationException("signature is invalid", ex);
|
||||||
}
|
}
|
||||||
@@ -92,5 +96,5 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
|
|||||||
private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) {
|
private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) {
|
||||||
return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass());
|
return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import sonia.scm.SCMContextProvider;
|
import sonia.scm.SCMContextProvider;
|
||||||
import sonia.scm.config.ScmConfiguration;
|
import sonia.scm.config.ScmConfiguration;
|
||||||
import sonia.scm.migration.UpdateStep;
|
import sonia.scm.migration.UpdateStep;
|
||||||
|
import sonia.scm.plugin.Extension;
|
||||||
import sonia.scm.security.AnonymousMode;
|
import sonia.scm.security.AnonymousMode;
|
||||||
import sonia.scm.store.ConfigurationStore;
|
import sonia.scm.store.ConfigurationStore;
|
||||||
import sonia.scm.store.ConfigurationStoreFactory;
|
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.XmlAccessType;
|
||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
import javax.xml.bind.annotation.XmlRootElement;
|
import javax.xml.bind.annotation.XmlRootElement;
|
||||||
import java.io.File;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
import static sonia.scm.version.Version.parse;
|
import static sonia.scm.version.Version.parse;
|
||||||
|
|
||||||
|
@Extension
|
||||||
public class AnonymousModeUpdateStep implements UpdateStep {
|
public class AnonymousModeUpdateStep implements UpdateStep {
|
||||||
|
|
||||||
private final SCMContextProvider contextProvider;
|
private final SCMContextProvider contextProvider;
|
||||||
@@ -62,12 +64,14 @@ public class AnonymousModeUpdateStep implements UpdateStep {
|
|||||||
Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION);
|
Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION);
|
||||||
|
|
||||||
if (configFile.toFile().exists()) {
|
if (configFile.toFile().exists()) {
|
||||||
|
PreUpdateScmConfiguration oldConfig = getPreUpdateScmConfigurationFromOldConfig(configFile);
|
||||||
ScmConfiguration config = configStore.get();
|
ScmConfiguration config = configStore.get();
|
||||||
if (getPreUpdateScmConfigurationFromOldConfig(configFile).isAnonymousAccessEnabled()) {
|
if (oldConfig.isAnonymousAccessEnabled()) {
|
||||||
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
||||||
} else {
|
} else {
|
||||||
config.setAnonymousMode(AnonymousMode.OFF);
|
config.setAnonymousMode(AnonymousMode.OFF);
|
||||||
}
|
}
|
||||||
|
configStore.set(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ public class AnonymousModeUpdateStep implements UpdateStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Path determineConfigDirectory() {
|
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")
|
@XmlRootElement(name = "scm-config")
|
||||||
|
|||||||
@@ -81,37 +81,37 @@ public class AuthenticationResourceTest {
|
|||||||
|
|
||||||
private static final String AUTH_JSON_TRILLIAN = "{\n" +
|
private static final String AUTH_JSON_TRILLIAN = "{\n" +
|
||||||
"\t\"cookie\": true,\n" +
|
"\t\"cookie\": true,\n" +
|
||||||
"\t\"grantType\": \"password\",\n" +
|
"\t\"grant_type\": \"password\",\n" +
|
||||||
"\t\"username\": \"trillian\",\n" +
|
"\t\"username\": \"trillian\",\n" +
|
||||||
"\t\"password\": \"secret\"\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" +
|
private static final String AUTH_JSON_TRILLIAN_WRONG_PW = "{\n" +
|
||||||
"\t\"cookie\": true,\n" +
|
"\t\"cookie\": true,\n" +
|
||||||
"\t\"grantType\": \"password\",\n" +
|
"\t\"grant_type\": \"password\",\n" +
|
||||||
"\t\"username\": \"trillian\",\n" +
|
"\t\"username\": \"trillian\",\n" +
|
||||||
"\t\"password\": \"justWrong\"\n" +
|
"\t\"password\": \"justWrong\"\n" +
|
||||||
"}";
|
"}";
|
||||||
|
|
||||||
private static final String AUTH_JSON_NOT_EXISTING_USER = "{\n" +
|
private static final String AUTH_JSON_NOT_EXISTING_USER = "{\n" +
|
||||||
"\t\"cookie\": true,\n" +
|
"\t\"cookie\": true,\n" +
|
||||||
"\t\"grantType\": \"password\",\n" +
|
"\t\"grant_type\": \"password\",\n" +
|
||||||
"\t\"username\": \"iDoNotExist\",\n" +
|
"\t\"username\": \"iDoNotExist\",\n" +
|
||||||
"\t\"password\": \"doesNotMatter\"\n" +
|
"\t\"password\": \"doesNotMatter\"\n" +
|
||||||
"}";
|
"}";
|
||||||
|
|
||||||
private static final String AUTH_JSON_WITHOUT_USERNAME = String.join("\n",
|
private static final String AUTH_JSON_WITHOUT_USERNAME = String.join("\n",
|
||||||
"{",
|
"{",
|
||||||
"\"grantType\": \"password\",",
|
"\"grant_type\": \"password\",",
|
||||||
"\"password\": \"tricia123\"",
|
"\"password\": \"tricia123\"",
|
||||||
"}"
|
"}"
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final String AUTH_JSON_WITHOUT_PASSWORD = String.join("\n",
|
private static final String AUTH_JSON_WITHOUT_PASSWORD = String.join("\n",
|
||||||
"{",
|
"{",
|
||||||
"\"grantType\": \"password\",",
|
"\"grant_type\": \"password\",",
|
||||||
"\"username\": \"trillian\"",
|
"\"username\": \"trillian\"",
|
||||||
"}"
|
"}"
|
||||||
);
|
);
|
||||||
@@ -125,7 +125,7 @@ public class AuthenticationResourceTest {
|
|||||||
|
|
||||||
private static final String AUTH_JSON_WITH_INVALID_GRANT_TYPE = String.join("\n",
|
private static final String AUTH_JSON_WITH_INVALID_GRANT_TYPE = String.join("\n",
|
||||||
"{",
|
"{",
|
||||||
"\"grantType\": \"el speciale\",",
|
"\"grant_type\": \"el speciale\",",
|
||||||
"\"username\": \"trillian\",",
|
"\"username\": \"trillian\",",
|
||||||
"\"password\": \"tricia123\"",
|
"\"password\": \"tricia123\"",
|
||||||
"}"
|
"}"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
@@ -42,9 +42,9 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ConfigDtoToScmConfigurationMapperImpl mapper;
|
private ConfigDtoToScmConfigurationMapperImpl mapper;
|
||||||
|
|
||||||
private String[] expectedUsers = { "trillian", "arthur" };
|
private String[] expectedUsers = {"trillian", "arthur"};
|
||||||
private String[] expectedGroups = { "admin", "plebs" };
|
private String[] expectedGroups = {"admin", "plebs"};
|
||||||
private String[] expectedExcludes = { "ex", "clude" };
|
private String[] expectedExcludes = {"ex", "clude"};
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void init() {
|
public void init() {
|
||||||
@@ -56,27 +56,42 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
ConfigDto dto = createDefaultDto();
|
ConfigDto dto = createDefaultDto();
|
||||||
ScmConfiguration config = mapper.map(dto);
|
ScmConfiguration config = mapper.map(dto);
|
||||||
|
|
||||||
assertEquals("prPw" , config.getProxyPassword());
|
assertEquals("prPw", config.getProxyPassword());
|
||||||
assertEquals(42 , config.getProxyPort());
|
assertEquals(42, config.getProxyPort());
|
||||||
assertEquals("srvr" , config.getProxyServer());
|
assertEquals("srvr", config.getProxyServer());
|
||||||
assertEquals("user" , config.getProxyUser());
|
assertEquals("user", config.getProxyUser());
|
||||||
assertTrue(config.isEnableProxy());
|
assertTrue(config.isEnableProxy());
|
||||||
assertEquals("realm" , config.getRealmDescription());
|
assertEquals("realm", config.getRealmDescription());
|
||||||
assertTrue(config.isDisableGroupingGrid());
|
assertTrue(config.isDisableGroupingGrid());
|
||||||
assertEquals("yyyy" , config.getDateFormat());
|
assertEquals("yyyy", config.getDateFormat());
|
||||||
assertTrue(config.getAnonymousMode() == AnonymousMode.FULL);
|
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
|
||||||
assertEquals("baseurl" , config.getBaseUrl());
|
assertEquals("baseurl", config.getBaseUrl());
|
||||||
assertTrue(config.isForceBaseUrl());
|
assertTrue(config.isForceBaseUrl());
|
||||||
assertEquals(41 , config.getLoginAttemptLimit());
|
assertEquals(41, config.getLoginAttemptLimit());
|
||||||
assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
|
assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
|
||||||
assertTrue(config.isSkipFailedAuthenticators());
|
assertTrue(config.isSkipFailedAuthenticators());
|
||||||
assertEquals("https://plug.ins" , config.getPluginUrl());
|
assertEquals("https://plug.ins", config.getPluginUrl());
|
||||||
assertEquals(40 , config.getLoginAttemptLimitTimeout());
|
assertEquals(40, config.getLoginAttemptLimitTimeout());
|
||||||
assertTrue(config.isEnabledXsrfProtection());
|
assertTrue(config.isEnabledXsrfProtection());
|
||||||
assertEquals("username", config.getNamespaceStrategy());
|
assertEquals("username", config.getNamespaceStrategy());
|
||||||
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
|
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() {
|
private ConfigDto createDefaultDto() {
|
||||||
ConfigDto configDto = new ConfigDto();
|
ConfigDto configDto = new ConfigDto();
|
||||||
configDto.setProxyPassword("prPw");
|
configDto.setProxyPassword("prPw");
|
||||||
@@ -87,7 +102,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
|
|||||||
configDto.setRealmDescription("realm");
|
configDto.setRealmDescription("realm");
|
||||||
configDto.setDisableGroupingGrid(true);
|
configDto.setDisableGroupingGrid(true);
|
||||||
configDto.setDateFormat("yyyy");
|
configDto.setDateFormat("yyyy");
|
||||||
configDto.setAnonymousMode(AnonymousMode.FULL);
|
configDto.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
|
||||||
configDto.setBaseUrl("baseurl");
|
configDto.setBaseUrl("baseurl");
|
||||||
configDto.setForceBaseUrl(true);
|
configDto.setForceBaseUrl(true);
|
||||||
configDto.setLoginAttemptLimit(41);
|
configDto.setLoginAttemptLimit(41);
|
||||||
|
|||||||
@@ -186,19 +186,6 @@ class MeDtoFactoryTest {
|
|||||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
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
|
@Test
|
||||||
void shouldAppendOnlySelfLinkIfAnonymousUser() {
|
void shouldAppendOnlySelfLinkIfAnonymousUser() {
|
||||||
User user = SCMContext.ANONYMOUS;
|
User user = SCMContext.ANONYMOUS;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.api.v2.resources;
|
package sonia.scm.api.v2.resources;
|
||||||
|
|
||||||
import org.apache.shiro.subject.Subject;
|
import org.apache.shiro.subject.Subject;
|
||||||
@@ -49,11 +49,11 @@ import static org.mockito.MockitoAnnotations.initMocks;
|
|||||||
|
|
||||||
public class ScmConfigurationToConfigDtoMapperTest {
|
public class ScmConfigurationToConfigDtoMapperTest {
|
||||||
|
|
||||||
private URI baseUri = URI.create("http://example.com/base/");
|
private URI baseUri = URI.create("http://example.com/base/");
|
||||||
|
|
||||||
private String[] expectedUsers = { "trillian", "arthur" };
|
private String[] expectedUsers = {"trillian", "arthur"};
|
||||||
private String[] expectedGroups = { "admin", "plebs" };
|
private String[] expectedGroups = {"admin", "plebs"};
|
||||||
private String[] expectedExcludes = { "ex", "clude" };
|
private String[] expectedExcludes = {"ex", "clude"};
|
||||||
|
|
||||||
@SuppressWarnings("unused") // Is injected
|
@SuppressWarnings("unused") // Is injected
|
||||||
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
|
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
|
||||||
@@ -87,22 +87,22 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
|||||||
when(subject.isPermitted("configuration:write:global")).thenReturn(true);
|
when(subject.isPermitted("configuration:write:global")).thenReturn(true);
|
||||||
ConfigDto dto = mapper.map(config);
|
ConfigDto dto = mapper.map(config);
|
||||||
|
|
||||||
assertEquals("heartOfGold" , dto.getProxyPassword());
|
assertEquals("heartOfGold", dto.getProxyPassword());
|
||||||
assertEquals(1234 , dto.getProxyPort());
|
assertEquals(1234, dto.getProxyPort());
|
||||||
assertEquals("proxyserver" , dto.getProxyServer());
|
assertEquals("proxyserver", dto.getProxyServer());
|
||||||
assertEquals("trillian" , dto.getProxyUser());
|
assertEquals("trillian", dto.getProxyUser());
|
||||||
assertTrue(dto.isEnableProxy());
|
assertTrue(dto.isEnableProxy());
|
||||||
assertEquals("description" , dto.getRealmDescription());
|
assertEquals("description", dto.getRealmDescription());
|
||||||
assertTrue(dto.isDisableGroupingGrid());
|
assertTrue(dto.isDisableGroupingGrid());
|
||||||
assertEquals("dd" , dto.getDateFormat());
|
assertEquals("dd", dto.getDateFormat());
|
||||||
assertSame(dto.getAnonymousMode(), AnonymousMode.FULL);
|
assertSame(AnonymousMode.FULL, dto.getAnonymousMode());
|
||||||
assertEquals("baseurl" , dto.getBaseUrl());
|
assertEquals("baseurl", dto.getBaseUrl());
|
||||||
assertTrue(dto.isForceBaseUrl());
|
assertTrue(dto.isForceBaseUrl());
|
||||||
assertEquals(1 , dto.getLoginAttemptLimit());
|
assertEquals(1, dto.getLoginAttemptLimit());
|
||||||
assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
|
assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
|
||||||
assertTrue(dto.isSkipFailedAuthenticators());
|
assertTrue(dto.isSkipFailedAuthenticators());
|
||||||
assertEquals("pluginurl" , dto.getPluginUrl());
|
assertEquals("pluginurl", dto.getPluginUrl());
|
||||||
assertEquals(2 , dto.getLoginAttemptLimitTimeout());
|
assertEquals(2, dto.getLoginAttemptLimitTimeout());
|
||||||
assertTrue(dto.isEnabledXsrfProtection());
|
assertTrue(dto.isEnabledXsrfProtection());
|
||||||
assertEquals("username", dto.getNamespaceStrategy());
|
assertEquals("username", dto.getNamespaceStrategy());
|
||||||
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
|
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
|
||||||
@@ -123,6 +123,21 @@ public class ScmConfigurationToConfigDtoMapperTest {
|
|||||||
assertFalse(dto.getLinks().hasLink("update"));
|
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() {
|
private ScmConfiguration createConfiguration() {
|
||||||
ScmConfiguration config = new ScmConfiguration();
|
ScmConfiguration config = new ScmConfiguration();
|
||||||
config.setProxyPassword("heartOfGold");
|
config.setProxyPassword("heartOfGold");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package sonia.scm.security;
|
package sonia.scm.security;
|
||||||
|
|
||||||
import org.apache.shiro.authc.AuthenticationInfo;
|
import org.apache.shiro.authc.AuthenticationInfo;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public class JwtAccessTokenResolverTest {
|
|||||||
String compact = createCompactToken("trillian", secureKey, exp, Scope.empty());
|
String compact = createCompactToken("trillian", secureKey, exp, Scope.empty());
|
||||||
|
|
||||||
// expect exception
|
// expect exception
|
||||||
expectedException.expect(AuthenticationException.class);
|
expectedException.expect(TokenExpiredException.class);
|
||||||
expectedException.expectCause(instanceOf(ExpiredJwtException.class));
|
expectedException.expectCause(instanceOf(ExpiredJwtException.class));
|
||||||
|
|
||||||
BearerToken bearer = BearerToken.valueOf(compact);
|
BearerToken bearer = BearerToken.valueOf(compact);
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ import java.io.IOException;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static sonia.scm.store.InMemoryConfigurationStoreFactory.create;
|
import static sonia.scm.store.InMemoryConfigurationStoreFactory.create;
|
||||||
|
|
||||||
@@ -60,8 +62,8 @@ class AnonymousModeUpdateStepTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void initUpdateStep(@TempDir Path tempDir) throws IOException {
|
void initUpdateStep(@TempDir Path tempDir) throws IOException {
|
||||||
when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile());
|
when(contextProvider.resolve(any(Path.class))).thenReturn(tempDir.toAbsolutePath());
|
||||||
configDir = tempDir.resolve("config");
|
configDir = tempDir;
|
||||||
Files.createDirectories(configDir);
|
Files.createDirectories(configDir);
|
||||||
InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create();
|
InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create();
|
||||||
configurationStore = inMemoryConfigurationStoreFactory.get("config", null);
|
configurationStore = inMemoryConfigurationStoreFactory.get("config", null);
|
||||||
|
|||||||
Reference in New Issue
Block a user