mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Merge pull request #24 from iflytek/feature/project-local
feat(auth): polish extensible login compatibility
This commit is contained in:
commit
28702bbbab
24 changed files with 343 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ private-sso
|
|||
|
||||
- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值
|
||||
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
|
||||
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
|
||||
|
||||
#### 步骤 2:封装 SSO 客户端
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=请求过于频繁,请稍后再试
|
||||
|
|
|
|||
|
|
@ -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<PlatformPrincipal> 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<PlatformPrincipal> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator {
|
|||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,9 @@ public interface DirectAuthProvider {
|
|||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
PlatformPrincipal authenticate(DirectAuthRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<HeadersInit> {
|
||||
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<T>(value: unknown): value is { data: T } {
|
|||
|
||||
async function unwrap<T>(promise: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
|
||||
const { data, error, response } = await promise
|
||||
if (response.status === 401) {
|
||||
throw new ApiError('HTTP 401', 401)
|
||||
const envelope = isApiEnvelope<T>(data) ? data : isApiEnvelope<T>(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<T> = {
|
|||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
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<T>(input: RequestInfo | URL, init?: RequestInit)
|
|||
}
|
||||
|
||||
export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise<string> {
|
||||
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<User | null> {
|
||||
try {
|
||||
const user = await unwrap<User>(client.GET('/api/v1/auth/me') as never)
|
||||
const user = await unwrap<User>(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<OAuthProvider[]> {
|
||||
const params = returnTo
|
||||
? { query: { returnTo } }
|
||||
: undefined
|
||||
const providers = await unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers', params as never) as never)
|
||||
const providers = await unwrap<OAuthProvider[]>(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<ApiToken[]> {
|
||||
const tokens = await unwrap<ApiToken[]>(client.GET('/api/v1/tokens') as never)
|
||||
const tokens = await unwrap<ApiToken[]>(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) => ({
|
||||
|
|
|
|||
|
|
@ -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<AuthMethod[]>({
|
||||
queryKey: ['auth', 'methods', returnTo ?? ''],
|
||||
queryFn: () => authApi.getMethods(returnTo),
|
||||
})
|
||||
const { data, isLoading } = useAuthMethods(returnTo)
|
||||
|
||||
const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT')
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ import { useSessionBootstrap } from './use-session-bootstrap'
|
|||
|
||||
interface SessionBootstrapEntryProps {
|
||||
onAuthenticated: () => Promise<void>
|
||||
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
|
|||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('login.enterpriseSsoTitle')}
|
||||
{providerName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{config.auto ? t('login.enterpriseSsoAutoHint') : t('login.enterpriseSsoHint')}
|
||||
{config.auto
|
||||
? t('login.enterpriseSsoAutoHint', { name: providerName })
|
||||
: t('login.enterpriseSsoHint', { name: providerName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -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 })}
|
||||
</Button>
|
||||
|
||||
{manualError ? (
|
||||
|
|
|
|||
10
web/src/features/auth/use-auth-methods.ts
Normal file
10
web/src/features/auth/use-auth-methods.ts
Normal file
|
|
@ -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<AuthMethod[]>({
|
||||
queryKey: ['auth', 'methods', returnTo ?? ''],
|
||||
queryFn: () => authApi.getMethods(returnTo),
|
||||
})
|
||||
}
|
||||
|
|
@ -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<User | null>(['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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserRating> {
|
|||
try {
|
||||
return await fetchJson<UserRating>(`/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
|
||||
|
|
|
|||
|
|
@ -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<StarStatus> {
|
|||
const starred = await fetchJson<boolean>(`/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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "未检测到可用的外部登录会话"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,10 +155,10 @@ export function LandingPage() {
|
|||
<Link to="/" className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-400">
|
||||
SkillHub
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 [&_button]:text-slate-200 [&_button:hover]:text-cyan-300 [&_svg]:text-slate-300">
|
||||
<LanguageSwitcher />
|
||||
<nav className="flex items-center gap-4">
|
||||
<LanguageSwitcher className="text-slate-200 hover:text-cyan-300" />
|
||||
{isLoading ? null : user ? (
|
||||
<UserMenu user={user} />
|
||||
<UserMenu user={user} triggerClassName="text-slate-200 hover:text-cyan-300" />
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
||||
import { LoginButton } from '@/features/auth/login-button'
|
||||
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
|
||||
import { useAuthMethods } from '@/features/auth/use-auth-methods'
|
||||
import { usePasswordLogin } from '@/features/auth/use-password-login'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
|
|
@ -18,8 +19,14 @@ export function LoginPage() {
|
|||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh'
|
||||
const { data: authMethods } = useAuthMethods(search.returnTo)
|
||||
|
||||
const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard'
|
||||
const directMethod = directAuthConfig.provider
|
||||
? authMethods?.find((method) =>
|
||||
method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider)
|
||||
: undefined
|
||||
const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP')
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -47,6 +54,7 @@ export function LoginPage() {
|
|||
<div className="glass-strong p-8 rounded-2xl">
|
||||
<div className="space-y-6">
|
||||
<SessionBootstrapEntry
|
||||
methodDisplayName={bootstrapMethod?.displayName}
|
||||
onAuthenticated={() => navigate({ to: returnTo })}
|
||||
/>
|
||||
|
||||
|
|
@ -60,7 +68,9 @@ export function LoginPage() {
|
|||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{directAuthConfig.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('login.passwordCompatHint')}
|
||||
{t('login.passwordCompatHint', {
|
||||
name: directMethod?.displayName ?? directAuthConfig.provider,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -8,7 +9,11 @@ import {
|
|||
} from '@/shared/ui/dropdown-menu'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const languages = [
|
||||
|
|
@ -27,9 +32,13 @@ export function LanguageSwitcher() {
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('gap-2 text-muted-foreground hover:text-foreground', className)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-sm">{currentLanguage.name}</span>
|
||||
<span className="text-sm text-inherit">{currentLanguage.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -18,9 +19,10 @@ interface User {
|
|||
|
||||
interface UserMenuProps {
|
||||
user: User
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
export function UserMenu({ user }: UserMenuProps) {
|
||||
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
@ -45,7 +47,7 @@ export function UserMenu({ user }: UserMenuProps) {
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
<button className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity', triggerClassName)}>
|
||||
{user.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
|
|
@ -54,7 +56,7 @@ export function UserMenu({ user }: UserMenuProps) {
|
|||
className="w-8 h-8 rounded-full border border-border/60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<span className="text-sm font-medium text-inherit">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
import i18n from '@/i18n/config'
|
||||
import { toast } from './toast'
|
||||
|
||||
function resolveLocalizedMessage(message?: string): string | undefined {
|
||||
if (!message) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return i18n.exists(message) ? i18n.t(message) : message
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public serverMessage?: string,
|
||||
) {
|
||||
super(message)
|
||||
super(resolveLocalizedMessage(message) || message)
|
||||
this.name = 'ApiError'
|
||||
this.serverMessage = resolveLocalizedMessage(serverMessage) || serverMessage
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,6 @@ export default defineConfig({
|
|||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/login': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue