diff --git a/docs/11-auth-extensibility-and-private-sso.md b/docs/11-auth-extensibility-and-private-sso.md index 4158f8f8..dcf66029 100644 --- a/docs/11-auth-extensibility-and-private-sso.md +++ b/docs/11-auth-extensibility-and-private-sso.md @@ -88,6 +88,12 @@ public interface DirectAuthProvider { - `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验 - 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点 +为减少私有 fork 的前端硬编码,扩展 provider 可额外声明展示名称: + +- `DirectAuthProvider.displayName()` 默认回退为 `providerCode()` +- `PassiveSessionAuthenticator.displayName()` 默认回退为 `providerCode()` +- `GET /api/v1/auth/methods` 会返回该展示名称,供登录页直接渲染 + ## 4. 本轮已落地内容 - 新增 `PassiveSessionAuthenticator` SPI diff --git a/docs/12-private-sso-integration-playbook.md b/docs/12-private-sso-integration-playbook.md index c7fc7dda..786317fb 100644 --- a/docs/12-private-sso-integration-playbook.md +++ b/docs/12-private-sso-integration-playbook.md @@ -167,6 +167,7 @@ private-sso - `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值 - 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code +- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射 #### 步骤 2:封装 SSO 客户端 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 2ec06f5f..3e1f53c8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -81,7 +81,7 @@ public class AuthMethodCatalog { "direct-" + provider.providerCode(), "DIRECT_PASSWORD", provider.providerCode(), - provider.providerCode(), + provider.displayName(), "/api/v1/auth/direct/login" ))); } @@ -93,7 +93,7 @@ public class AuthMethodCatalog { "bootstrap-" + provider.providerCode(), "SESSION_BOOTSTRAP", provider.providerCode(), - provider.providerCode(), + provider.displayName(), "/api/v1/auth/session/bootstrap" ))); } diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8ab6ad5e..329db1c0 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -17,6 +17,18 @@ validation.member.role.notNull=Role is required validation.token.name.notBlank=Token name cannot be blank error.auth.required=Authentication required +error.auth.local.username.exists=Username already exists +error.auth.local.email.exists=Email already exists +error.auth.local.password.tooShort=Password must be at least 8 characters +error.auth.local.password.tooLong=Password must not exceed 128 characters +error.auth.local.password.tooWeak=Password must include at least 3 character types +error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores +error.auth.local.invalidCredentials=Incorrect username or password +error.auth.local.notEnabled=Local account login is not enabled for this user +error.auth.local.accountDisabled=This account has been disabled +error.auth.local.accountPending=This account is pending activation +error.auth.local.accountMerged=This account has been merged and can no longer be used to log in +error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s) error.auth.direct.disabled=Direct authentication compatibility is disabled error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0} error.auth.sessionBootstrap.disabled=Session bootstrap is disabled diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 9480c9df..6d839aea 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -17,6 +17,23 @@ validation.member.role.notNull=角色不能为空 validation.token.name.notBlank=Token 名称不能为空 error.auth.required=需要先登录 +error.auth.local.username.exists=用户名已存在 +error.auth.local.email.exists=邮箱已存在 +error.auth.local.password.tooShort=密码至少需要 8 位 +error.auth.local.password.tooLong=密码长度不能超过 128 位 +error.auth.local.password.tooWeak=密码至少需要包含 3 种字符类型 +error.auth.local.username.invalid=用户名需为 3-64 位,且只能包含字母、数字或下划线 +error.auth.local.invalidCredentials=用户名或密码错误 +error.auth.local.notEnabled=当前用户未启用本地账号登录 +error.auth.local.accountDisabled=该账号已被禁用 +error.auth.local.accountPending=该账号尚未激活 +error.auth.local.accountMerged=该账号已合并,不能再用于登录 +error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试 +error.auth.direct.disabled=直连认证兼容层未启用 +error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} +error.auth.sessionBootstrap.disabled=会话引导能力未启用 +error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} +error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 error.rateLimit.exceeded=请求过于频繁,请稍后再试 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java new file mode 100644 index 00000000..35ca8d75 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; +import com.iflytek.skillhub.config.DirectAuthProperties; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; + +class AuthMethodCatalogTest { + + @Test + void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise Password"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + throw new UnsupportedOperationException("not used in catalog test"); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public String displayName() { + return "Enterprise SSO"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "local-password:Local Account", + "direct-private-sso:Enterprise Password", + "bootstrap-private-sso:Enterprise SSO" + ); + } + + @Test + void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() { + OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); + DirectAuthProperties directAuthProperties = new DirectAuthProperties(); + directAuthProperties.setEnabled(true); + AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); + bootstrapProperties.setEnabled(true); + + DirectAuthProvider directProvider = new DirectAuthProvider() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + return mock(PlatformPrincipal.class); + } + }; + + PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() { + @Override + public String providerCode() { + return "private-sso"; + } + + @Override + public Optional authenticate(jakarta.servlet.http.HttpServletRequest request) { + return Optional.empty(); + } + }; + + AuthMethodCatalog catalog = new AuthMethodCatalog( + oauthProperties, + directAuthProperties, + bootstrapProperties, + List.of(directProvider), + List.of(bootstrapProvider) + ); + + assertThat(catalog.listMethods(null)) + .extracting(method -> method.id() + ":" + method.displayName()) + .contains( + "direct-private-sso:private-sso", + "bootstrap-private-sso:private-sso" + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java index 72be3565..ef00284b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java @@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator { String providerCode(); + default String displayName() { + return providerCode(); + } + Optional authenticate(HttpServletRequest request); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java index 7f62e53f..eb9b4331 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java @@ -9,5 +9,9 @@ public interface DirectAuthProvider { String providerCode(); + default String displayName() { + return providerCode(); + } + PlatformPrincipal authenticate(DirectAuthRequest request); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java index 45f4d4ff..181b85a3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/LocalDirectAuthProvider.java @@ -18,6 +18,11 @@ public class LocalDirectAuthProvider implements DirectAuthProvider { return "local"; } + @Override + public String displayName() { + return "Local Account"; + } + @Override public PlatformPrincipal authenticate(DirectAuthRequest request) { return localAuthService.login(request.username(), request.password()); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 08f2850f..465dfddc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -21,6 +21,7 @@ import type { User, } from './types' import { ApiError } from '@/shared/lib/api-error' +import i18n from '@/i18n/config' export { ApiError } @@ -65,21 +66,31 @@ function getCsrfToken(): string | null { return match ? decodeURIComponent(match[1]) : null } +function withRequestHeaders(headers?: HeadersInit): Headers { + const merged = new Headers(headers) + const language = i18n.resolvedLanguage?.trim() + if (language) { + merged.set('Accept-Language', language) + } + return merged +} + function withCsrf(headers?: HeadersInit): HeadersInit { + const merged = withRequestHeaders(headers) const csrfToken = getCsrfToken() if (!csrfToken) { - return headers ?? {} + return merged } - return { - ...headers, - 'X-XSRF-TOKEN': csrfToken, - } + merged.set('X-XSRF-TOKEN', csrfToken) + return merged } async function ensureCsrfHeaders(headers?: HeadersInit): Promise { if (!getCsrfToken()) { - await client.GET('/api/v1/auth/providers') + await client.GET('/api/v1/auth/providers', { + headers: withRequestHeaders(), + } as never) } return withCsrf(headers) } @@ -94,11 +105,13 @@ function hasDataProperty(value: unknown): value is { data: T } { async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { const { data, error, response } = await promise - if (response.status === 401) { - throw new ApiError('HTTP 401', 401) + const envelope = isApiEnvelope(data) ? data : isApiEnvelope(error) ? error : null + + if (!response.ok) { + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) } if (error) { - throw new ApiError(`HTTP ${response.status}`, response.status) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) } if (data === undefined) { throw new ApiError(`HTTP ${response.status}`, response.status) @@ -160,7 +173,10 @@ type ApiEnvelope = { export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { let response: Response try { - response = await fetch(withBaseUrl(input), init) + response = await fetch(withBaseUrl(input), { + ...init, + headers: withRequestHeaders(init?.headers), + }) } catch { throw new ApiError('Network error', 0) } @@ -184,7 +200,10 @@ export async function fetchJson(input: RequestInfo | URL, init?: RequestInit) } export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(withBaseUrl(input), init) + const response = await fetch(withBaseUrl(input), { + ...init, + headers: withRequestHeaders(init?.headers), + }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } @@ -205,7 +224,9 @@ function ensureTrailingSlash(value: string): string { export async function getCurrentUser(): Promise { try { - const user = await unwrap(client.GET('/api/v1/auth/me') as never) + const user = await unwrap(client.GET('/api/v1/auth/me', { + headers: withRequestHeaders(), + } as never) as never) return { ...user, userId: user.userId ?? '', @@ -224,10 +245,10 @@ export const authApi = { getMe: getCurrentUser, async getProviders(returnTo?: string): Promise { - const params = returnTo - ? { query: { returnTo } } - : undefined - const providers = await unwrap(client.GET('/api/v1/auth/providers', params as never) as never) + const providers = await unwrap(client.GET('/api/v1/auth/providers', { + ...(returnTo ? { query: { returnTo } } : {}), + headers: withRequestHeaders(), + } as never) as never) return providers .filter((provider) => provider.id && provider.name && provider.authorizationUrl) .map((provider) => ({ @@ -352,7 +373,9 @@ export const accountApi = { export const tokenApi = { async getTokens(): Promise { - const tokens = await unwrap(client.GET('/api/v1/tokens') as never) + const tokens = await unwrap(client.GET('/api/v1/tokens', { + headers: withRequestHeaders(), + } as never) as never) return tokens .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) .map((token) => ({ diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index d1c3cbeb..a7dad6d1 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -1,8 +1,6 @@ -import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { authApi } from '@/api/client' import { Button } from '@/shared/ui/button' -import type { AuthMethod } from '@/api/types' +import { useAuthMethods } from './use-auth-methods' interface LoginButtonProps { returnTo?: string @@ -10,10 +8,7 @@ interface LoginButtonProps { export function LoginButton({ returnTo }: LoginButtonProps) { const { t } = useTranslation() - const { data, isLoading } = useQuery({ - queryKey: ['auth', 'methods', returnTo ?? ''], - queryFn: () => authApi.getMethods(returnTo), - }) + const { data, isLoading } = useAuthMethods(returnTo) const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx index 98ab85af..eb2651e2 100644 --- a/web/src/features/auth/session-bootstrap-entry.tsx +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -6,13 +6,15 @@ import { useSessionBootstrap } from './use-session-bootstrap' interface SessionBootstrapEntryProps { onAuthenticated: () => Promise + methodDisplayName?: string } -export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntryProps) { +export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: SessionBootstrapEntryProps) { const { t } = useTranslation() const config = getSessionBootstrapRuntimeConfig() const bootstrapMutation = useSessionBootstrap() const attemptedRef = useRef(false) + const providerName = methodDisplayName || t('login.enterpriseSsoTitle') useEffect(() => { if (!config.enabled || !config.provider || !config.auto || attemptedRef.current) { @@ -43,10 +45,12 @@ export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntry

- {t('login.enterpriseSsoTitle')} + {providerName}

- {config.auto ? t('login.enterpriseSsoAutoHint') : t('login.enterpriseSsoHint')} + {config.auto + ? t('login.enterpriseSsoAutoHint', { name: providerName }) + : t('login.enterpriseSsoHint', { name: providerName })}

@@ -66,7 +70,9 @@ export function SessionBootstrapEntry({ onAuthenticated }: SessionBootstrapEntry }) }} > - {bootstrapMutation.isPending ? t('login.enterpriseSsoSubmitting') : t('login.enterpriseSsoAction')} + {bootstrapMutation.isPending + ? t('login.enterpriseSsoSubmitting', { name: providerName }) + : t('login.enterpriseSsoAction', { name: providerName })} {manualError ? ( diff --git a/web/src/features/auth/use-auth-methods.ts b/web/src/features/auth/use-auth-methods.ts new file mode 100644 index 00000000..b05c82ce --- /dev/null +++ b/web/src/features/auth/use-auth-methods.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { AuthMethod } from '@/api/types' + +export function useAuthMethods(returnTo?: string) { + return useQuery({ + queryKey: ['auth', 'methods', returnTo ?? ''], + queryFn: () => authApi.getMethods(returnTo), + }) +} diff --git a/web/src/features/auth/use-password-login.ts b/web/src/features/auth/use-password-login.ts index 106d9238..a1ce3716 100644 --- a/web/src/features/auth/use-password-login.ts +++ b/web/src/features/auth/use-password-login.ts @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi, getDirectAuthRuntimeConfig } from '@/api/client' +import { ApiError } from '@/shared/lib/api-error' import type { LocalLoginRequest, User } from '@/api/types' export function usePasswordLogin() { @@ -16,5 +17,12 @@ export function usePasswordLogin() { onSuccess: (user) => { queryClient.setQueryData(['auth', 'me'], user) }, + onError: (error) => { + // Keep invalid credentials on the login page instead of falling back to the + // global 401 redirect handler used for background API requests. + if (error instanceof ApiError) { + return + } + }, }) } diff --git a/web/src/features/social/use-rating.ts b/web/src/features/social/use-rating.ts index 403c905c..6ebe43cc 100644 --- a/web/src/features/social/use-rating.ts +++ b/web/src/features/social/use-rating.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' +import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client' interface UserRating { score: number @@ -10,7 +10,7 @@ async function getUserRating(skillId: number): Promise { try { return await fetchJson(`/api/v1/skills/${skillId}/rating`) } catch (error) { - if (error instanceof Error && error.message === 'HTTP 401') { + if (error instanceof ApiError && error.status === 401) { return { score: 0, rated: false } } throw error diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 3e9aed97..c4106756 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { fetchJson, getCsrfHeaders } from '@/api/client' +import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client' interface StarStatus { starred: boolean @@ -10,7 +10,7 @@ async function getStarStatus(skillId: number): Promise { const starred = await fetchJson(`/api/v1/skills/${skillId}/star`) return { starred } } catch (error) { - if (error instanceof Error && error.message === 'HTTP 401') { + if (error instanceof ApiError && error.status === 401) { return { starred: false } } throw error diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 81ab1452..8ac9a317 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -106,7 +106,7 @@ "title": "Login to SkillHub", "subtitle": "Choose a method to continue", "tabPassword": "Password", - "tabOAuth": "GitHub", + "tabOAuth": "OAuth", "username": "Username", "password": "Password", "usernamePlaceholder": "Enter username", @@ -116,12 +116,12 @@ "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", - "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to the configured direct authentication provider instead of the fixed local account endpoint.", + "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 an enterprise SSO session, you can try establishing a SkillHub session directly.", - "enterpriseSsoAutoHint": "This deployment has automatic enterprise SSO probing enabled. If it does not succeed, you can continue with the standard login methods.", - "enterpriseSsoAction": "Try Enterprise SSO", - "enterpriseSsoSubmitting": "Trying 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.", + "enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.", + "enterpriseSsoAction": "Try {{name}}", + "enterpriseSsoSubmitting": "Trying {{name}}...", "agreementPrefix": "By logging in, you agree to our", "terms": "Terms of Service", "and": "and", @@ -536,5 +536,25 @@ "serverError": "Server error, please try again later", "networkError": "Network connection failed, please check your network", "unknown": "Operation failed" + }, + "error": { + "auth": { + "local": { + "invalidCredentials": "Incorrect username or password", + "accountDisabled": "This account has been disabled", + "accountPending": "This account is pending activation", + "accountMerged": "This account has been merged and can no longer be used to log in", + "locked": "Too many failed attempts. Please try again later" + }, + "direct": { + "disabled": "Direct authentication compatibility is disabled", + "providerUnsupported": "This login method is not supported" + }, + "sessionBootstrap": { + "disabled": "Session bootstrap is disabled", + "providerUnsupported": "This SSO bootstrap method is not supported", + "notAuthenticated": "No authenticated external session was found" + } + } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8ea8b579..031e3d41 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -106,7 +106,7 @@ "title": "登录 SkillHub", "subtitle": "选择一个方式登录以继续", "tabPassword": "账号密码", - "tabOAuth": "GitHub", + "tabOAuth": "OAuth", "username": "用户名", "password": "密码", "usernamePlaceholder": "输入用户名", @@ -116,12 +116,12 @@ "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", - "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到配置的直连认证提供方,而不是固定使用本地账号接口。", + "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", - "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在企业 SSO 会话,可直接尝试建立 SkillHub 登录态。", - "enterpriseSsoAutoHint": "当前部署已启用自动企业单点登录探测。若未成功,你仍可继续使用现有登录方式。", - "enterpriseSsoAction": "尝试企业 SSO 登录", - "enterpriseSsoSubmitting": "正在尝试企业 SSO 登录...", + "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。", + "enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。", + "enterpriseSsoAction": "尝试 {{name}} 登录", + "enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...", "agreementPrefix": "登录即表示你同意我们的", "terms": "服务条款", "and": "和", @@ -536,5 +536,25 @@ "serverError": "服务器错误,请稍后重试", "networkError": "网络连接失败,请检查网络", "unknown": "操作失败" + }, + "error": { + "auth": { + "local": { + "invalidCredentials": "用户名或密码错误", + "accountDisabled": "该账号已被禁用", + "accountPending": "该账号尚未激活", + "accountMerged": "该账号已合并,不能再用于登录", + "locked": "连续失败次数过多,请稍后再试" + }, + "direct": { + "disabled": "直连认证兼容层未启用", + "providerUnsupported": "当前登录方式不受支持" + }, + "sessionBootstrap": { + "disabled": "会话引导能力未启用", + "providerUnsupported": "当前 SSO 会话引导方式不受支持", + "notAuthenticated": "未检测到可用的外部登录会话" + } + } } } diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index c3550931..201cf014 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -155,10 +155,10 @@ export function LandingPage() { SkillHub -