diff --git a/app/routes/api_auth.py b/app/routes/api_auth.py index 554b86f..cf09df0 100644 --- a/app/routes/api_auth.py +++ b/app/routes/api_auth.py @@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.security import HTTPBearer -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from app.auth.jwt import create_access_token @@ -33,6 +35,20 @@ class AuthRequest(BaseModel): email: str = Field(min_length=1, max_length=254) password: str = Field(min_length=8) + @field_validator("email") + @classmethod + def _normalize_email(cls, v: str) -> str: + v = v.strip().lower() + local, _, domain = v.partition("@") + if not local or "." not in domain or " " in v: + raise ValueError("Enter a valid email address") + return v + + +def _find_user_by_email(session: Session, email: str) -> User | None: + # Case-insensitive so accounts created before emails were normalized still match. + return session.exec(select(User).where(func.lower(User.email) == email)).first() + class TokenPairResponse(BaseModel): access_token: str @@ -50,12 +66,17 @@ class RefreshRequest(BaseModel): @router.post("/register", response_model=TokenPairResponse, status_code=201) def register(body: AuthRequest, session: Session = Depends(get_session)): - if session.exec(select(User).where(User.email == body.email)).first(): + if _find_user_by_email(session, body.email): raise HTTPException(status_code=409, detail="Email already registered") user = User(email=body.email, password_hash=hash_password(body.password)) session.add(user) - session.commit() + try: + session.commit() + except IntegrityError: + # Two simultaneous signups with the same email: unique index wins. + session.rollback() + raise HTTPException(status_code=409, detail="Email already registered") from None session.refresh(user) uid = user.id assert uid is not None @@ -73,7 +94,7 @@ def register(body: AuthRequest, session: Session = Depends(get_session)): @router.post("/login", response_model=TokenPairResponse) def login(body: AuthRequest, session: Session = Depends(get_session)): - user = session.exec(select(User).where(User.email == body.email)).first() + user = _find_user_by_email(session, body.email) if not user or not verify_password(body.password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid email or password") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 54698d4..3f7cd73 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,22 @@ export class ApiError extends Error { } } +// FastAPI returns `detail` as a string for HTTPException but as an array of +// {loc, msg, ...} objects for 422 validation errors. Turn either into text. +function formatDetail(detail: unknown, fallback: string): string { + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) { + const msgs = detail + .map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : '')) + .filter(Boolean) + if (msgs.length) return msgs.join('; ') + } + return fallback +} + +const NETWORK_ERROR_MESSAGE = + "Can't reach the server. It may be waking up (free hosting) — please try again in a minute." + async function apiFetchRaw(path: string, options: RequestInit = {}): Promise { const token = localStorage.getItem('access_token') const headers: Record = { @@ -15,10 +31,15 @@ async function apiFetchRaw(path: string, options: RequestInit = {}): Promise< ...(options.headers as Record), } if (token) headers['Authorization'] = `Bearer ${token}` - const res = await fetch(`${BASE}${path}`, { ...options, headers }) + let res: Response + try { + res = await fetch(`${BASE}${path}`, { ...options, headers }) + } catch { + throw new ApiError(0, NETWORK_ERROR_MESSAGE) + } if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })) - throw new ApiError(res.status, body.detail ?? res.statusText) + throw new ApiError(res.status, formatDetail(body.detail, res.statusText || 'Request failed')) } if (res.status === 204) return undefined as unknown as T return res.json() diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index d4d426a..e324a29 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -21,7 +21,7 @@ export default function Register() { await register(fd.get('email') as string, fd.get('password') as string) navigate('/profile') } catch (err) { - setError(err instanceof ApiError ? err.message : 'Something went wrong') + setError(err instanceof ApiError ? err.message : 'Unexpected response from the server. Please try again.') } finally { setLoading(false) } } @@ -33,14 +33,20 @@ export default function Register() {

Create account

{error &&

{error}

}
- - + {loading && ( +

+ The server may be waking up — this can take up to a minute on first use. +

+ )}

Have an account? Log in

diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 7313401..072045d 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -63,6 +63,46 @@ def test_duplicate_registration_conflicts(client): assert resp.status_code == 409 +def test_duplicate_registration_is_case_insensitive(client): + client.post( + "/api/auth/register", + json={"email": "Case@X.com", "password": "password123"}, + ) + resp = client.post( + "/api/auth/register", + json={"email": " case@x.COM ", "password": "password456"}, + ) + assert resp.status_code == 409 + + +def test_login_email_is_case_insensitive(client): + client.post( + "/api/auth/register", + json={"email": "mixed@x.com", "password": "password123"}, + ) + resp = client.post( + "/api/auth/login", + json={"email": "MIXED@x.com", "password": "password123"}, + ) + assert resp.status_code == 200 + + +def test_register_rejects_invalid_email(client): + resp = client.post( + "/api/auth/register", + json={"email": "not-an-email", "password": "password123"}, + ) + assert resp.status_code == 422 + + +def test_register_rejects_short_password(client): + resp = client.post( + "/api/auth/register", + json={"email": "short@x.com", "password": "short"}, + ) + assert resp.status_code == 422 + + def test_history_persists_after_assess(registered_client): registered_client.post( "/api/assess",