fix(auth): establish Manager session cookie after Heicode password login
External IdP login alone did not set Gin session; proxied API calls returned 401 and triggered session-expired toast. Call POST /api/user/login after token exchange, support Turnstile on sign-in, handle 2FA pending session, and clear Manager cookie on logout. Made-with: Cursor
This commit is contained in:
+57
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { resetHeicodeAuthenticatedSession } from '@/features/auth/heicode-authenticated-session'
|
import { resetHeicodeAuthenticatedSession } from '@/features/auth/heicode-authenticated-session'
|
||||||
|
import { saveUserId } from '@/features/auth/lib/storage'
|
||||||
import type {
|
import type {
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -44,6 +45,40 @@ export function clearHeicodeTokens() {
|
|||||||
resetHeicodeAuthenticatedSession()
|
resetHeicodeAuthenticatedSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在外部 Heicode 登录成功后,向本站点 Manager 写入会话 Cookie(否则 /api/* 会 401 →「会话已过期」) */
|
||||||
|
async function establishManagerCookieSession(payload: LoginPayload) {
|
||||||
|
const params =
|
||||||
|
payload.turnstile && payload.turnstile.length > 0
|
||||||
|
? { turnstile: payload.turnstile }
|
||||||
|
: undefined
|
||||||
|
const res = await api.post(
|
||||||
|
'/api/user/login',
|
||||||
|
{
|
||||||
|
username: payload.username,
|
||||||
|
password: payload.password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
skipBusinessError: true,
|
||||||
|
skipErrorHandler: true,
|
||||||
|
} as Record<string, unknown>
|
||||||
|
)
|
||||||
|
const body = res.data as {
|
||||||
|
success?: boolean
|
||||||
|
message?: string
|
||||||
|
data?: { require_2fa?: boolean; id?: number }
|
||||||
|
}
|
||||||
|
if (!body?.success) {
|
||||||
|
throw new Error(body?.message || 'Unable to establish Manager session')
|
||||||
|
}
|
||||||
|
if (body.data?.require_2fa) {
|
||||||
|
throw new Error('TWO_FACTOR_REQUIRED')
|
||||||
|
}
|
||||||
|
if (body.data?.id != null) {
|
||||||
|
saveUserId(body.data.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function callHeicodeAuth<T>(
|
async function callHeicodeAuth<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
@@ -113,6 +148,18 @@ export async function login(payload: LoginPayload) {
|
|||||||
})
|
})
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
writeTokens(res.data?.token, res.data?.refreshToken)
|
writeTokens(res.data?.token, res.data?.refreshToken)
|
||||||
|
try {
|
||||||
|
await establishManagerCookieSession(payload)
|
||||||
|
} catch (syncErr) {
|
||||||
|
if (
|
||||||
|
syncErr instanceof Error &&
|
||||||
|
syncErr.message === 'TWO_FACTOR_REQUIRED'
|
||||||
|
) {
|
||||||
|
throw syncErr
|
||||||
|
}
|
||||||
|
clearHeicodeTokens()
|
||||||
|
throw syncErr
|
||||||
|
}
|
||||||
} else if (res?.detail || res?.message) {
|
} else if (res?.detail || res?.message) {
|
||||||
throw new Error(res.detail || res.message || 'Login failed')
|
throw new Error(res.detail || res.message || 'Login failed')
|
||||||
}
|
}
|
||||||
@@ -134,8 +181,18 @@ export async function login2fa(payload: TwoFAPayload) {
|
|||||||
|
|
||||||
// User logout
|
// User logout
|
||||||
export async function logout(): Promise<ApiResponse> {
|
export async function logout(): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
await api.get('/api/user/logout', {
|
||||||
|
skipErrorHandler: true,
|
||||||
|
skipBusinessError: true,
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await callHeicodeAuth('/api/auth/logout', { method: 'POST' })
|
await callHeicodeAuth('/api/auth/logout', { method: 'POST' })
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
} finally {
|
} finally {
|
||||||
clearHeicodeTokens()
|
clearHeicodeTokens()
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -20,6 +20,8 @@ import { PasswordInput } from '@/components/password-input'
|
|||||||
import { login } from '@/features/auth/api'
|
import { login } from '@/features/auth/api'
|
||||||
import { loginFormSchema } from '@/features/auth/constants'
|
import { loginFormSchema } from '@/features/auth/constants'
|
||||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||||
|
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||||
|
import { Turnstile } from '@/components/turnstile'
|
||||||
import type { AuthFormProps } from '@/features/auth/types'
|
import type { AuthFormProps } from '@/features/auth/types'
|
||||||
|
|
||||||
export function UserAuthForm({
|
export function UserAuthForm({
|
||||||
@@ -30,7 +32,14 @@ export function UserAuthForm({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const loginFailedMessage = t('Login failed')
|
const loginFailedMessage = t('Login failed')
|
||||||
const { handleLoginSuccess } = useAuthRedirect()
|
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
|
||||||
|
const {
|
||||||
|
isTurnstileEnabled,
|
||||||
|
turnstileSiteKey,
|
||||||
|
turnstileToken,
|
||||||
|
setTurnstileToken,
|
||||||
|
validateTurnstile,
|
||||||
|
} = useTurnstile()
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof loginFormSchema>>({
|
const form = useForm<z.infer<typeof loginFormSchema>>({
|
||||||
resolver: zodResolver(loginFormSchema),
|
resolver: zodResolver(loginFormSchema),
|
||||||
@@ -41,11 +50,13 @@ export function UserAuthForm({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
|
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
|
||||||
|
if (!validateTurnstile()) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await login({
|
const res = await login({
|
||||||
username: data.username,
|
username: data.username,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
turnstile: turnstileToken || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
@@ -67,6 +78,13 @@ export function UserAuthForm({
|
|||||||
toast.error(res.message)
|
toast.error(res.message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === 'TWO_FACTOR_REQUIRED'
|
||||||
|
) {
|
||||||
|
redirectTo2FA()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -132,6 +150,10 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isTurnstileEnabled && (
|
||||||
|
<Turnstile siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
className='h-12 w-full justify-center gap-2 rounded-xl text-sm font-semibold shadow-[0_18px_48px_-22px_color-mix(in_oklch,var(--primary)_75%,black)]'
|
className='h-12 w-full justify-center gap-2 rounded-xl text-sm font-semibold shadow-[0_18px_48px_-22px_color-mix(in_oklch,var(--primary)_75%,black)]'
|
||||||
|
|||||||
Reference in New Issue
Block a user