Merge pull request #24 from iflytek/feature/project-local

feat(auth): polish extensible login compatibility
This commit is contained in:
yun-zhi-ztl 2026-03-14 04:08:44 -07:00 committed by GitHub
commit 28702bbbab
24 changed files with 343 additions and 61 deletions

View file

@ -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

View file

@ -167,6 +167,7 @@ private-sso
- `DirectAuthProvider.providerCode()``PassiveSessionAuthenticator.providerCode()` 返回同一个值
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
#### 步骤 2封装 SSO 客户端

View file

@ -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"
)));
}

View file

@ -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

View file

@ -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=请求过于频繁,请稍后再试

View file

@ -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"
);
}
}

View file

@ -12,5 +12,9 @@ public interface PassiveSessionAuthenticator {
String providerCode();
default String displayName() {
return providerCode();
}
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
}

View file

@ -9,5 +9,9 @@ public interface DirectAuthProvider {
String providerCode();
default String displayName() {
return providerCode();
}
PlatformPrincipal authenticate(DirectAuthRequest request);
}

View file

@ -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());

View file

@ -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) => ({

View file

@ -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')

View file

@ -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 ? (

View 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),
})
}

View file

@ -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
}
},
})
}

View file

@ -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

View file

@ -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

View file

@ -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"
}
}
}
}

View file

@ -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": "未检测到可用的外部登录会话"
}
}
}
}

View file

@ -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"

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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
}
}

View file

@ -20,10 +20,6 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/login': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})