mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
feat(auth): add GitLab OAuth2 provider support
Add GitLab as an additional OAuth2 authentication provider alongside GitHub. This includes: - GitLab OAuth2 client configuration with customizable base URL - GitLabClaimsExtractor for handling GitLab-specific user claims - Multi-provider login UI with provider-specific icons - Updated localization to use OAuth-agnostic terminology - JSON type annotation for IdentityBinding entity
This commit is contained in:
parent
010c1a4e46
commit
d1459aea83
11 changed files with 212 additions and 14 deletions
|
|
@ -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: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/github
|
||||
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: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/gitlab
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
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() {
|
||||
this.restClient = RestClient.builder()
|
||||
.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");
|
||||
|
||||
// GitLab provides email_verified field in the user info response
|
||||
Boolean emailVerifiedObj = (Boolean) attrs.get("email_verified");
|
||||
boolean emailVerified = emailVerifiedObj != null && emailVerifiedObj;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a GitLab email object from the /user/emails API.
|
||||
*
|
||||
* @param email the email address
|
||||
* @param confirmed whether the email has been confirmed
|
||||
*/
|
||||
private record GitLabEmail(String email, boolean confirmed) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
2
web/public/github-logo.svg
Normal file
2
web/public/github-logo.svg
Normal 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 |
2
web/public/gitlab-logo.svg
Normal file
2
web/public/gitlab-logo.svg
Normal 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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@
|
|||
"submit": "Login",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@
|
|||
"submit": "登录",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "立即注册",
|
||||
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
|
||||
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
|
||||
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
|
||||
"enterpriseSsoTitle": "企业单点登录",
|
||||
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue