diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 78b921b..55bed7a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -29,6 +29,8 @@ function setAuth(accountType: NonNullable['account_typ ? { id: `${accountType}-1`, email: `${accountType}@demo.invalid`, display_name: '模拟用户', account_type: accountType, global_capabilities: [] } : null, isLoading: false, + isLoggingOut: false, + sessionNotice: null, login: vi.fn(), logout: vi.fn(), } diff --git a/frontend/src/auth/AuthContext.test.tsx b/frontend/src/auth/AuthContext.test.tsx index b23f269..6f4b617 100644 --- a/frontend/src/auth/AuthContext.test.tsx +++ b/frontend/src/auth/AuthContext.test.tsx @@ -12,10 +12,11 @@ function jsonResponse(status: number, body: unknown): Response { } function AuthProbe() { - const { login, logout, user } = useAuth() + const { login, logout, sessionNotice, user } = useAuth() return (
{user ? user.email : 'anonymous'} + {sessionNotice && {sessionNotice}}
@@ -76,6 +77,23 @@ describe('AuthProvider', () => { expect(sessionStorage.getItem('angui.session.token')).toBeNull() }) + it('completes the local logout when remote revocation fails', async () => { + sessionStorage.setItem('angui.session.token', 'existing-session') + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(200, { + id: 'family-1', email: 'family@demo.invalid', display_name: '模拟家属', account_type: 'member', global_capabilities: [], + })) + .mockResolvedValueOnce(jsonResponse(503, { error: { code: 'service_unavailable' } })) + vi.stubGlobal('fetch', fetchMock) + render() + + expect(await screen.findByText('family@demo.invalid')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '退出' })) + + expect(await screen.findByText('anonymous')).toBeInTheDocument() + expect(sessionStorage.getItem('angui.session.token')).toBeNull() + }) + it('removes stale session data when an authenticated request receives 401', async () => { sessionStorage.setItem('angui.session.token', 'existing-session') vi.stubGlobal( @@ -91,6 +109,7 @@ describe('AuthProvider', () => { expect(await screen.findByText('family@demo.invalid')).toBeInTheDocument() await expect(apiRequest('/cases', {}, 'existing-session')).rejects.toMatchObject({ status: 401 }) await waitFor(() => expect(screen.getByText('anonymous')).toBeInTheDocument()) + expect(screen.getByRole('status')).toHaveTextContent('登录状态已失效,请重新登录。') expect(sessionStorage.getItem('angui.session.token')).toBeNull() }) }) diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index e3ed387..8cc6ecd 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -16,15 +16,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY)) const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(Boolean(token)) - const clearSession = useCallback(() => { + const [isLoggingOut, setIsLoggingOut] = useState(false) + const [sessionNotice, setSessionNotice] = useState(null) + const clearSession = useCallback((notice?: string) => { sessionStorage.removeItem(TOKEN_KEY) setToken(null) setUser(null) + if (notice) setSessionNotice(notice) }, []) useEffect(() => { - window.addEventListener(SESSION_EXPIRED_EVENT, clearSession) - return () => window.removeEventListener(SESSION_EXPIRED_EVENT, clearSession) + const handleSessionExpired = () => { + clearSession('登录状态已失效,请重新登录。') + } + window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired) + return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired) }, [clearSession]) useEffect(() => { @@ -58,22 +64,29 @@ export function AuthProvider({ children }: { children: ReactNode }) { user, token, isLoading, + isLoggingOut, + sessionNotice, login: async (email, password) => { const response = await requestLogin(email, password) sessionStorage.setItem(TOKEN_KEY, response.token) + setSessionNotice(null) setToken(response.token) setUser(response.user) }, logout: async () => { if (!token) return + setIsLoggingOut(true) try { await requestLogout(token) + } catch { + // Remote revocation is best effort; always complete the local safety exit. } finally { clearSession() + setIsLoggingOut(false) } }, }), - [clearSession, isLoading, token, user], + [clearSession, isLoading, isLoggingOut, sessionNotice, token, user], ) return {children} diff --git a/frontend/src/auth/auth-context.ts b/frontend/src/auth/auth-context.ts index a431c94..6e33b65 100644 --- a/frontend/src/auth/auth-context.ts +++ b/frontend/src/auth/auth-context.ts @@ -5,6 +5,8 @@ export interface AuthContextValue { user: AuthUser | null token: string | null isLoading: boolean + isLoggingOut: boolean + sessionNotice: string | null login: (email: string, password: string) => Promise logout: () => Promise } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d5ce091..dae4f7d 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { Button, Chip } from '@heroui/react' +import { Button, Chip, Spinner } from '@heroui/react' import { HeartHandshake, LayoutDashboard, @@ -43,7 +43,7 @@ function identityLabel(accountType: AccountType, capabilities: GlobalCapability[ } export function AppShell() { - const { user, logout } = useAuth() + const { user, logout, isLoggingOut } = useAuth() const visibleNavigation = navigation.filter((item) => user && (item.to === '/' || user.account_type === 'member')) return ( @@ -95,9 +95,9 @@ export function AppShell() { )} - @@ -108,8 +108,8 @@ export function AppShell() { {user?.display_name} {user ? identityLabel(user.account_type, user.global_capabilities) : ''} - diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..40f6e4f --- /dev/null +++ b/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AuthContextValue } from '../auth/auth-context' +import { LoginPage } from './LoginPage' + +const mocked = vi.hoisted(() => ({ + auth: null as AuthContextValue | null, +})) + +vi.mock('../auth/useAuth', () => ({ useAuth: () => mocked.auth })) + +function setAuth(login: AuthContextValue['login'] = vi.fn().mockResolvedValue(undefined)) { + mocked.auth = { + user: null, + token: null, + isLoading: false, + isLoggingOut: false, + sessionNotice: null, + login, + logout: vi.fn(), + } +} + +describe('LoginPage', () => { + it('shows field errors and returns focus to the first missing field', () => { + setAuth() + render() + + fireEvent.click(screen.getByRole('button', { name: '登录' })) + + expect(screen.getByText('请输入邮箱地址。')).toHaveAttribute('role', 'alert') + expect(screen.getByLabelText('邮箱')).toHaveFocus() + }) + + it('lets people reveal or conceal their password while typing', () => { + setAuth() + render() + + const passwordInput = screen.getByLabelText('密码') + expect(passwordInput).toHaveAttribute('type', 'password') + + fireEvent.click(screen.getByRole('button', { name: '显示密码' })) + expect(passwordInput).toHaveAttribute('type', 'text') + + fireEvent.click(screen.getByRole('button', { name: '隐藏密码' })) + expect(passwordInput).toHaveAttribute('type', 'password') + }) + + it('uses a trimmed email and removes a server error when credentials change', async () => { + const login = vi.fn().mockRejectedValue(new Error('邮箱或密码错误,请重新输入。')) + setAuth(login) + render() + + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: ' family@demo.invalid ' } }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'local-test-input' } }) + fireEvent.click(screen.getByRole('button', { name: '登录' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('邮箱或密码错误,请重新输入。') + expect(login).toHaveBeenCalledWith('family@demo.invalid', 'local-test-input') + + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'new-input' } }) + await waitFor(() => expect(screen.queryByText('邮箱或密码错误,请重新输入。')).not.toBeInTheDocument()) + }) +}) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 266cf4a..c09b19f 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,22 +1,59 @@ -import { Button, Card, Chip, Input } from '@heroui/react' -import { LockKeyhole, LogIn, ShieldCheck } from 'lucide-react' -import { useState } from 'react' +import { Button, Card, Chip, Input, Spinner } from '@heroui/react' +import { Eye, EyeOff, LogIn, ShieldCheck } from 'lucide-react' +import { useRef, useState } from 'react' +import type { FormEvent } from 'react' import brandMark from '../../../assets/brand/angui-mark.svg' import { useAuth } from '../auth/useAuth' +function hasValidEmail(value: string) { + return value.trim().includes('@') +} + export function LoginPage() { - const { login } = useAuth() + const { login, sessionNotice } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [isPasswordVisible, setIsPasswordVisible] = useState(false) + const [isEmailTouched, setIsEmailTouched] = useState(false) + const [isPasswordTouched, setIsPasswordTouched] = useState(false) + const emailInputRef = useRef(null) + const passwordInputRef = useRef(null) + + const emailError = isEmailTouched + ? (email.trim() ? (hasValidEmail(email) ? '' : '请输入有效的邮箱地址。') : '请输入邮箱地址。') + : '' + const passwordError = isPasswordTouched && !password ? '请输入密码。' : '' + + function updateEmail(value: string) { + setEmail(value) + if (error) setError('') + } - async function handleSubmit(event: React.FormEvent) { + function updatePassword(value: string) { + setPassword(value) + if (error) setError('') + } + + async function handleSubmit(event: FormEvent) { event.preventDefault() + setIsEmailTouched(true) + setIsPasswordTouched(true) + + if (!hasValidEmail(email)) { + emailInputRef.current?.focus() + return + } + if (!password) { + passwordInputRef.current?.focus() + return + } + setError('') setIsSubmitting(true) try { - await login(email, password) + await login(email.trim(), password) } catch (cause) { setError(cause instanceof Error ? cause.message : '登录失败') } finally { @@ -49,39 +86,76 @@ export function LoginPage() { -
-