Merge branch 'main' of github.com:iflytek/skillhub

This commit is contained in:
River 2026-04-21 14:48:02 +08:00
commit 9ced6bb94d
20 changed files with 455 additions and 36 deletions

View file

@ -56,6 +56,13 @@ DEVICE_AUTH_VERIFICATION_URI=
OAUTH2_GITHUB_CLIENT_ID=
OAUTH2_GITHUB_CLIENT_SECRET=
# Optional: configure real GitLab OAuth before exposing the stack to other users.
# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable.
OAUTH2_GITLAB_CLIENT_ID=
OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587

View file

@ -168,6 +168,63 @@ set_env_value() {
mv "$tmp" "$ENV_FILE"
}
get_env_value() {
key="$1"
default_value="${2:-}"
value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)"
if [ -n "$value" ]; then
printf '%s' "$value"
else
printf '%s' "$default_value"
fi
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
attempt=1
while [ "$attempt" -le 60 ]; do
if run_compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
echo "PostgreSQL did not become ready in time." >&2
run_compose logs postgres >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
if [ -z "$postgres_password" ]; then
echo "POSTGRES_PASSWORD must not be empty." >&2
exit 1
fi
wait_for_postgres_ready "$postgres_user" "$postgres_db"
run_compose exec -T postgres \
psql -U "$postgres_user" -d "$postgres_db" \
-v ON_ERROR_STOP=1 \
-v password="$postgres_password" <<'SQL' >/dev/null
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
\gexec
SQL
run_compose exec -T -e "PGPASSWORD=$postgres_password" postgres \
psql -h 127.0.0.1 -U "$postgres_user" -d "$postgres_db" \
-v ON_ERROR_STOP=1 \
-c 'select current_user;' >/dev/null
}
prepare_runtime_files() {
mkdir -p "$SKILLHUB_HOME"
download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE"
@ -235,6 +292,8 @@ prepare_runtime_files
case "$COMMAND" in
up)
run_compose up -d postgres
ensure_postgres_password_matches_env
if [ "$DISABLE_SCANNER" = "true" ]; then
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
else

View file

@ -104,6 +104,63 @@ set_env_value() {
mv "${tmp}" .env.release
}
get_env_value() {
key="$1"
default_value="${2:-}"
value="$(grep -E "^${key}=" .env.release | tail -n 1 | cut -d= -f2- || true)"
if [[ -n "${value}" ]]; then
printf '%s' "${value}"
else
printf '%s' "${default_value}"
fi
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
for attempt in $(seq 1 60); do
if docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "PostgreSQL did not become ready in time" >&2
docker compose --env-file .env.release -f compose.release.yml logs postgres >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")"
if [[ -z "${postgres_password}" ]]; then
echo "POSTGRES_PASSWORD must not be empty" >&2
exit 1
fi
wait_for_postgres_ready "${postgres_user}" "${postgres_db}"
docker compose --env-file .env.release -f compose.release.yml exec -T postgres \
psql -U "${postgres_user}" -d "${postgres_db}" \
-v ON_ERROR_STOP=1 \
-v password="${postgres_password}" <<'SQL' >/dev/null
SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password');
\gexec
SQL
docker compose --env-file .env.release -f compose.release.yml exec -T \
-e PGPASSWORD="${postgres_password}" postgres \
psql -h 127.0.0.1 -U "${postgres_user}" -d "${postgres_db}" \
-v ON_ERROR_STOP=1 \
-c 'select current_user;' >/dev/null
}
cd "${runtime_dir}"
test -f .env.release
@ -123,6 +180,8 @@ run_url=${run_url}
METADATA
docker compose --env-file .env.release -f compose.release.yml pull
docker compose --env-file .env.release -f compose.release.yml up -d postgres
ensure_postgres_password_matches_env
docker compose --env-file .env.release -f compose.release.yml up -d
docker compose --env-file .env.release -f compose.release.yml ps

View file

@ -122,10 +122,8 @@ public class SkillSearchAppService {
}
private boolean hasPlatformWideReadAccess(Set<String> platformRoles) {
if (platformRoles == null || platformRoles.isEmpty()) {
return false;
}
return platformRoles.contains("SUPER_ADMIN");
// Super admins should use a dedicated admin interface, not the public portal
return false;
}
private SearchResponse searchVisibleSkills(

View file

@ -53,10 +53,29 @@ spring:
github:
client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder}
scope: read:user,user:email
scope:
- read:user
- user:email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: GitHub
authorization-grant-type: authorization_code
gitlab:
client-id: ${OAUTH2_GITLAB_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET:placeholder}
scope:
- read_user
- email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
provider:
github:
user-info-uri: https://api.github.com/user
gitlab:
authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
user-name-attribute: username
servlet:
multipart:
max-file-size: 100MB

View file

@ -142,11 +142,12 @@ class AuthControllerTest {
mockMvc.perform(get("/api/v1/auth/providers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee")))
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github",
"/oauth2/authorization/gitee"
"/oauth2/authorization/gitee",
"/oauth2/authorization/gitlab"
)))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());

View file

@ -216,7 +216,7 @@ class SkillSearchAppServiceTest {
}
@Test
void search_shouldGrantPlatformWideAccessToSuperAdmin() {
void search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal() {
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER"));
@ -228,7 +228,7 @@ class SkillSearchAppServiceTest {
SearchVisibilityScope scope = captor.getValue().visibilityScope();
assertEquals("admin-1", scope.userId());
assertEquals(true, scope.platformWideAccess());
assertEquals(false, scope.platformWideAccess());
}
private void setField(Object target, String fieldName, Object value) {

View file

@ -1,9 +1,21 @@
package com.iflytek.skillhub.auth.entity;
import jakarta.persistence.*;
import java.time.Clock;
import java.time.Instant;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "identity_binding",
uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"}))
@ -24,6 +36,7 @@ public class IdentityBinding {
@Column(name = "login_name", length = 128)
private String loginName;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "extra_json", columnDefinition = "jsonb")
private String extraJson;

View file

@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequ
PlatformPrincipal principal = context.principal();
var attrs = new HashMap<>(context.upstreamUser().getAttributes());
attrs.put("platformPrincipal", principal);
// Store providerLogin under a fixed key so DefaultOAuth2User can find it
attrs.put("providerLogin", principal.userId());
var authorities = new LinkedHashSet<GrantedAuthority>(context.upstreamUser().getAuthorities());
principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
return new DefaultOAuth2User(authorities, attrs, "login");
return new DefaultOAuth2User(authorities, attrs, "providerLogin");
}
}

View file

@ -0,0 +1,142 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
/**
* Provider-specific claims extractor that enriches GitLab OAuth users with their
* verified email information.
*
* <p>GitLab OAuth2 user info endpoint returns user profile data. This extractor
* fetches additional email information from GitLab API when needed.
*/
@Component
public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
private static final Logger log = LoggerFactory.getLogger(GitLabClaimsExtractor.class);
private final RestClient restClient;
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
@Override
public String getProvider() {
return "gitlab";
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet());
// GitLab returns email directly in user info
String email = (String) attrs.get("email");
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
// If email is not verified or not present, try to fetch from emails API
if (email == null || !emailVerified) {
log.debug("Email not verified or missing, attempting to fetch from GitLab emails API");
GitLabEmail primaryEmail = loadPrimaryEmail(request);
if (primaryEmail != null) {
email = primaryEmail.email();
emailVerified = true;
log.debug("Found verified email from GitLab API: {}", email);
} else {
log.debug("No verified email found from GitLab emails API");
}
}
// GitLab uses "username" for login name
String username = (String) attrs.get("username");
if (username == null) {
username = (String) attrs.get("login");
}
String subject = String.valueOf(attrs.get("id"));
log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
subject, username, email, emailVerified);
return new OAuthClaims(
"gitlab",
subject,
email,
emailVerified,
username,
attrs
);
}
private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) {
String baseUrl = getGitLabApiBaseUrl(request);
log.debug("Loading primary email from GitLab API base URL: {}", baseUrl);
try {
List<GitLabEmail> emails = restClient.get()
.uri(baseUrl + "/user/emails")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<List<GitLabEmail>>() {});
if (emails == null || emails.isEmpty()) {
log.debug("No emails returned from GitLab emails API");
return null;
}
log.debug("Retrieved {} emails from GitLab API", emails.size());
// Return the primary verified email
return emails.stream()
.filter(GitLabEmail::confirmed)
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage());
return null;
}
}
/**
* Determines the GitLab API base URL from the provider configuration.
* The user-info-uri is configured as ${OAUTH2_GITLAB_BASE_URI}/api/v4/user,
* so we simply remove the /user suffix to get the API base URL.
*/
private String getGitLabApiBaseUrl(OAuth2UserRequest request) {
String userInfoUri = request.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri();
log.debug("GitLab user info URI: {}", userInfoUri);
// user-info-uri format: ${OAUTH2_GITLAB_BASE_URI}/api/v4/user
// Remove /user suffix to get API base URL
String baseUrl = userInfoUri.substring(0, userInfoUri.length() - "/user".length());
log.debug("GitLab API base URL: {}", baseUrl);
return baseUrl;
}
/**
* GitLab marks a confirmed email by populating confirmed_at.
*/
private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) {
boolean confirmed() {
return confirmedAt != null && !confirmedAt.isBlank();
}
}
private boolean isConfirmed(Object confirmedAt) {
return confirmedAt instanceof String value && !value.isBlank();
}
}

View file

@ -1,7 +1,5 @@
package com.iflytek.skillhub.auth.session;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -10,6 +8,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Service;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
/**
* Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's
* session-backed authentication context.
@ -53,7 +55,13 @@ public class PlatformSessionService {
Authentication authentication,
HttpServletRequest request,
boolean rotateSessionId) {
persist(principal, authentication, request, rotateSessionId);
// Create a new authentication with PlatformPrincipal as the principal
// instead of using the OAuth2 authentication which has OAuth2User as principal
var authorities = principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
Authentication platformAuth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
persist(principal, platformAuth, request, rotateSessionId);
}
private void persist(PlatformPrincipal principal,

View file

@ -0,0 +1,103 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class GitLabClaimsExtractorTest {
@Test
void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() {
RestClient.Builder restClientBuilder = RestClient.builder();
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
OAuthClaims claims = extractor.extract(
userRequest(),
new DefaultOAuth2User(
java.util.List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice@gitlab.example",
"confirmed_at", "2026-04-16T08:00:00Z"
),
"username"
)
);
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
assertThat(claims.emailVerified()).isTrue();
assertThat(claims.providerLogin()).isEqualTo("alice");
}
@Test
void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails"))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andRespond(withSuccess(
"""
[
{"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"},
{"email":"alice+pending@gitlab.example","confirmed_at":null}
]
""",
MediaType.APPLICATION_JSON
));
GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder);
OAuthClaims claims = extractor.extract(
userRequest(),
new DefaultOAuth2User(
java.util.List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice+pending@gitlab.example"
),
"username"
)
);
assertThat(claims.email()).isEqualTo("alice@gitlab.example");
assertThat(claims.emailVerified()).isTrue();
server.verify();
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("read_user", "email")
.authorizationUri("https://gitlab.example.com/oauth/authorize")
.tokenUri("https://gitlab.example.com/oauth/token")
.userInfoUri("https://gitlab.example.com/api/v4/user")
.userNameAttributeName("username")
.clientName("GitLab")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -61,7 +61,7 @@ class OAuth2LoginHandlersTest {
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal);
assertThat(securityContext).isNotNull();
assertThat(securityContext.getAuthentication()).isSameAs(authentication);
assertThat(securityContext.getAuthentication().getPrincipal()).isEqualTo(principal);
}
@Test

View file

@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
Set<Long> adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().adminNamespaceIds();
boolean platformWideAccess = query.visibilityScope().platformWideAccess();
StringBuilder sql = new StringBuilder();
sql.append("SELECT d.skill_id ");
@ -118,9 +117,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (d.visibility = 'PUBLIC' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) ");
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) ");
sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) ");
sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) ");
}
sql.append(") ");
@ -131,7 +127,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (n.status <> 'ARCHIVED' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR d.namespace_id IN :memberNamespaceIds ");
sql.append("OR :platformWideAccess = TRUE ");
}
sql.append(") ");
@ -196,8 +191,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
nativeQuery.setParameter("platformWideAccess", platformWideAccess);
nativeQuery.setParameter("userId", query.visibilityScope().userId());
}
if (query.namespaceId() != null) {
@ -243,8 +236,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
countQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
countQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
countQuery.setParameter("platformWideAccess", platformWideAccess);
countQuery.setParameter("userId", query.visibilityScope().userId());
}
if (query.namespaceId() != null) {

View file

@ -393,7 +393,7 @@ class PostgresFullTextQueryServiceTest {
}
@Test
void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() {
void platformWideAccessShouldNotBypassVisibilityInPortalSearch() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
@ -418,12 +418,12 @@ class PostgresFullTextQueryServiceTest {
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
// Portal search should not include platformWideAccess bypass logic
assertThat(sqlCaptor.getAllValues().getFirst())
.contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)")
.contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)")
.contains("OR :platformWideAccess = TRUE");
verify(nativeQuery).setParameter("platformWideAccess", true);
verify(countQuery).setParameter("platformWideAccess", true);
.doesNotContain("platformWideAccess")
.doesNotContain("PRIVATE");
verify(nativeQuery, never()).setParameter("platformWideAccess", true);
verify(countQuery, never()).setParameter("platformWideAccess", true);
}
@Test

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 -0.5 25 25" xmlns="http://www.w3.org/2000/svg"><path d="m12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031c1.871 1.094 3.386 2.609 4.449 4.422l.031.058c1.04 1.769 1.654 3.896 1.654 6.166 0 5.406-3.483 10-8.327 11.658l-.087.026c-.063.02-.135.031-.209.031-.162 0-.312-.054-.433-.144l.002.001c-.128-.115-.208-.281-.208-.466 0-.005 0-.01 0-.014v.001q0-.048.008-1.226t.008-2.154c.007-.075.011-.161.011-.249 0-.792-.323-1.508-.844-2.025.618-.061 1.176-.163 1.718-.305l-.076.017c.573-.16 1.073-.373 1.537-.642l-.031.017c.508-.28.938-.636 1.292-1.058l.006-.007c.372-.476.663-1.036.84-1.645l.009-.035c.209-.683.329-1.468.329-2.281 0-.045 0-.091-.001-.136v.007c0-.022.001-.047.001-.072 0-1.248-.482-2.383-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479 0-.649-.145-1.263-.404-1.814l.011.026c-.115-.022-.246-.035-.381-.035-.334 0-.649.078-.929.216l.012-.005c-.568.21-1.054.448-1.512.726l.038-.022-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433c-.373-.214-.814-.421-1.272-.595l-.066-.022c-.293-.154-.64-.244-1.009-.244-.124 0-.246.01-.364.03l.013-.002c-.248.524-.393 1.139-.393 1.788 0 .531.097 1.04.275 1.509l-.01-.029c-.785.844-1.266 1.979-1.266 3.227 0 .025 0 .051.001.076v-.004c-.001.039-.001.084-.001.13 0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013c.354.435.782.793 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012c-.207.101-.448.183-.699.236l-.021.004c-.256.051-.549.08-.85.08-.022 0-.044 0-.066 0h.003c-.394-.008-.756-.136-1.055-.348l.006.004c-.371-.259-.671-.595-.881-.986l-.007-.015c-.198-.336-.459-.614-.768-.827l-.009-.006c-.225-.169-.49-.301-.776-.38l-.016-.004-.32-.048c-.023-.002-.05-.003-.077-.003-.14 0-.273.028-.394.077l.007-.003q-.128.072-.08.184c.039.086.087.16.145.225l-.001-.001c.061.072.13.135.205.19l.003.002.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007c.045.002.097.002.15.002.261 0 .517-.021.767-.062l-.027.004.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001c-.119.089-.268.143-.431.143-.075 0-.147-.011-.214-.032l.005.001c-4.929-1.689-8.409-6.283-8.409-11.69 0-2.268.612-4.393 1.681-6.219l-.032.058c1.094-1.871 2.609-3.386 4.422-4.449l.058-.031c1.739-1.034 3.835-1.645 6.073-1.645h.098-.005zm-7.64 17.666q.048-.112-.112-.192-.16-.048-.208.032-.048.112.112.192.144.096.208-.032zm.497.545q.112-.08-.032-.256-.16-.144-.256-.048-.112.08.032.256.159.157.256.047zm.48.72q.144-.112 0-.304-.128-.208-.272-.096-.144.08 0 .288t.272.112zm.672.673q.128-.128-.064-.304-.192-.192-.32-.048-.144.128.064.304.192.192.32.044zm.913.4q.048-.176-.208-.256-.24-.064-.304.112t.208.24q.24.097.304-.096zm1.009.08q0-.208-.272-.176-.256 0-.256.176 0 .208.272.176.256.001.256-.175zm.929-.16q-.032-.176-.288-.144-.256.048-.224.24t.288.128.225-.224z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#FC6D26" d="M14.975 8.904L14.19 6.55l-1.552-4.67a.268.268 0 00-.255-.18.268.268 0 00-.254.18l-1.552 4.667H5.422L3.87 1.879a.267.267 0 00-.254-.179.267.267 0 00-.254.18l-1.55 4.667-.784 2.357a.515.515 0 00.193.583l6.78 4.812 6.778-4.812a.516.516 0 00.196-.583z"/><path fill="#E24329" d="M8 14.296l2.578-7.75H5.423L8 14.296z"/><path fill="#FC6D26" d="M8 14.296l-2.579-7.75H1.813L8 14.296z"/><path fill="#FCA326" d="M1.81 6.549l-.784 2.354a.515.515 0 00.193.583L8 14.3 1.81 6.55z"/><path fill="#E24329" d="M1.812 6.549h3.612L3.87 1.882a.268.268 0 00-.254-.18.268.268 0 00-.255.18L1.812 6.549z"/><path fill="#FC6D26" d="M8 14.296l2.578-7.75h3.614L8 14.296z"/><path fill="#FCA326" d="M14.19 6.549l.783 2.354a.514.514 0 01-.193.583L8 14.296l6.188-7.747h.001z"/><path fill="#E24329" d="M14.19 6.549H10.58l1.551-4.667a.267.267 0 01.255-.18c.115 0 .217.073.254.18l1.552 4.667z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -6,6 +6,20 @@ interface LoginButtonProps {
returnTo?: string
}
/**
* Returns the appropriate icon for a given OAuth provider.
*/
function OAuthIcon({ provider }: { provider: string }) {
const normalizedProvider = provider.toLowerCase()
return (
<img
src={`/${normalizedProvider}-logo.svg`}
alt={provider}
className="w-5 h-5 mr-3"
/>
)
}
/**
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
*/
@ -37,12 +51,11 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
window.location.href = provider.actionUrl
}}
>
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<OAuthIcon provider={provider.provider} />
{t('loginButton.loginWith', { name: provider.displayName })}
</Button>
))}
</div>
)
}

View file

@ -212,7 +212,7 @@
"forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"register": "Sign up now",
"oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.",
"oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.",
"passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.",
"enterpriseSsoTitle": "Enterprise SSO",
"enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.",

View file

@ -212,7 +212,7 @@
"forgotPassword": "忘记密码?",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
"enterpriseSsoTitle": "企业单点登录",
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",