Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function setAuth(accountType: NonNullable<AuthContextValue['user']>['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(),
}
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/auth/AuthContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<span>{user ? user.email : 'anonymous'}</span>
{sessionNotice && <span role="status">{sessionNotice}</span>}
<button type="button" onClick={() => void login('family@demo.invalid', 'local-test-input').catch(() => undefined)}>登录</button>
<button type="button" onClick={() => void logout()}>退出</button>
</div>
Expand Down Expand Up @@ -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(<AuthProvider><AuthProbe /></AuthProvider>)

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(
Expand All @@ -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()
})
})
21 changes: 17 additions & 4 deletions frontend/src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => sessionStorage.getItem(TOKEN_KEY))
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(Boolean(token))
const clearSession = useCallback(() => {
const [isLoggingOut, setIsLoggingOut] = useState(false)
const [sessionNotice, setSessionNotice] = useState<string | null>(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(() => {
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
}),
[clearSession, isLoading, token, user],
[clearSession, isLoading, isLoggingOut, sessionNotice, token, user],
)

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/auth/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
logout: () => Promise<void>
}
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Chip } from '@heroui/react'
import { Button, Chip, Spinner } from '@heroui/react'
import {
HeartHandshake,
LayoutDashboard,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -95,9 +95,9 @@ export function AppShell() {
</div>
)}
<ServiceStatus compact />
<Button className="mt-3" size="sm" variant="ghost" fullWidth onPress={() => void logout()}>
<LogOut size={16} aria-hidden="true" />
退出登录
<Button className="mt-3" size="sm" variant="ghost" fullWidth isDisabled={isLoggingOut} onPress={() => void logout()}>
{isLoggingOut ? <Spinner size="sm" aria-label="正在退出登录" /> : <LogOut size={16} aria-hidden="true" />}
{isLoggingOut ? '正在退出' : '退出登录'}
</Button>
</div>
</aside>
Expand All @@ -108,8 +108,8 @@ export function AppShell() {
<strong className="block truncate text-sm text-slate-950">{user?.display_name}</strong>
<span className="block truncate text-xs text-slate-500">{user ? identityLabel(user.account_type, user.global_capabilities) : ''}</span>
</div>
<Button size="sm" variant="ghost" isIconOnly aria-label="退出登录" onPress={() => void logout()}>
<LogOut size={17} />
<Button size="sm" variant="ghost" isIconOnly aria-label={isLoggingOut ? '正在退出登录' : '退出登录'} isDisabled={isLoggingOut} onPress={() => void logout()}>
{isLoggingOut ? <Spinner size="sm" /> : <LogOut size={17} aria-hidden="true" />}
</Button>
</div>
<Outlet />
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/pages/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoginPage />)

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(<LoginPage />)

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(<LoginPage />)

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())
})
})
118 changes: 96 additions & 22 deletions frontend/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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('@')
}
Comment on lines +8 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

使用实际的邮箱格式校验。

由于表单设置了 noValidate,a@、@demo.invalid 或包含多个 @ 的值都会通过当前校验并发送登录请求,违反字段级校验预期。

建议修复
 function hasValidEmail(value: string) {
-  return value.trim().includes('@')
+  return /^[^\s@]+@[^\s@]+$/.test(value.trim())
 }

As per path instructions,frontend/** 需要关注输入校验与用户可见失败路径。

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function hasValidEmail(value: string) {
return value.trim().includes('@')
}
function hasValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+$/.test(value.trim())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/LoginPage.tsx` around lines 8 - 10, Update hasValidEmail
in LoginPage.tsx to perform actual email-format validation rather than only
checking for an '@' character, rejecting values such as a@, `@demo.invalid`, and
addresses containing multiple '@' characters while preserving valid email input
behavior.

Source: Path instructions


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<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) {
function updatePassword(value: string) {
setPassword(value)
if (error) setError('')
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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 {
Expand Down Expand Up @@ -49,39 +86,76 @@ export function LoginPage() {
</div>
</Card.Header>
<Card.Content className="p-5">
<form className="space-y-4" onSubmit={handleSubmit}>
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-slate-600">邮箱</span>
<form className="space-y-4" noValidate onSubmit={handleSubmit}>
{sessionNotice && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-950" role="status" aria-live="polite">
{sessionNotice}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700" htmlFor="login-email">邮箱</label>
<Input
ref={emailInputRef}
id="login-email"
type="email"
autoComplete="username"
autoFocus
inputMode="email"
enterKeyHint="next"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setIsEmailTouched(true)}
onChange={(event) => updateEmail(event.target.value)}
aria-describedby={emailError ? 'login-email-error' : undefined}
aria-invalid={emailError ? 'true' : undefined}
className="min-h-11"
disabled={isSubmitting}
fullWidth
required
placeholder="name@example.com"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-slate-600">密码</span>
<Input
type="password"
{emailError && <p id="login-email-error" className="mt-1.5 text-sm text-red-700" role="alert">{emailError}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700" htmlFor="login-password">密码</label>
<div className="relative">
<Input
ref={passwordInputRef}
id="login-password"
type={isPasswordVisible ? 'text' : 'password'}
autoComplete="current-password"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
onBlur={() => setIsPasswordTouched(true)}
onChange={(event) => updatePassword(event.target.value)}
aria-describedby={passwordError || error ? 'login-password-error' : undefined}
aria-invalid={passwordError || error ? 'true' : undefined}
className="min-h-11 pr-12"
disabled={isSubmitting}
fullWidth
required
/>
</label>
/>
<Button
type="button"
size="sm"
variant="ghost"
isIconOnly
className="absolute right-0.5 top-1/2 size-11 min-h-11 min-w-11 -translate-y-1/2"
aria-label={isPasswordVisible ? '隐藏密码' : '显示密码'}
isDisabled={isSubmitting}
onPress={() => setIsPasswordVisible((visible) => !visible)}
>
{isPasswordVisible ? <EyeOff size={18} aria-hidden="true" /> : <Eye size={18} aria-hidden="true" />}
</Button>
</div>
{passwordError && <p id="login-password-error" className="mt-1.5 text-sm text-red-700" role="alert">{passwordError}</p>}
</div>

{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert">
<div id="login-password-error" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
{error}
</div>
)}

<Button type="submit" variant="primary" fullWidth isDisabled={isSubmitting}>
{isSubmitting ? <LockKeyhole size={17} /> : <LogIn size={17} />}
{isSubmitting ? <Spinner size="sm" aria-label="正在验证登录信息" /> : <LogIn size={17} aria-hidden="true" />}
{isSubmitting ? '正在验证' : '登录'}
</Button>
</form>
Expand Down
Loading