Repository navigation
feat(frontend): 完善认证入口与登录体验 (#69) #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()) | ||
| }) | ||
| }) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 使用实际的邮箱格式校验。 由于表单设置了 建议修复 function hasValidEmail(value: string) {
- return value.trim().includes('@')
+ return /^[^\s@]+@[^\s@]+$/.test(value.trim())
}As per path instructions, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: 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 { | ||||||||||||||
|
|
@@ -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> | ||||||||||||||
|
|
||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.