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
29 changes: 25 additions & 4 deletions app/routes/api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")

Expand Down
25 changes: 23 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,38 @@ 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<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
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()
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/pages/Register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

Expand All @@ -33,14 +33,20 @@ export default function Register() {
<h1 className="text-xl font-medium mb-4">Create account</h1>
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-3">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3">
<input name="email" type="email" placeholder="name@company.com" required
<input name="email" type="email" placeholder="name@company.com" required autoComplete="email"
className="w-full border border-slate-300 rounded-lg px-3 py-3 text-base bg-white" />
<input name="password" type="password" placeholder="Password" required
<input name="password" type="password" placeholder="Password (min. 8 characters)" required
minLength={8} autoComplete="new-password"
className="w-full border border-slate-300 rounded-lg px-3 py-3 text-base bg-white" />
<button disabled={loading}
className="w-full bg-slate-900 text-white rounded-lg px-4 py-3 text-base font-medium disabled:opacity-60">
{loading ? 'Creating account…' : 'Register'}
</button>
{loading && (
<p className="text-xs text-slate-500 text-center">
The server may be waking up — this can take up to a minute on first use.
</p>
)}
</form>
<p className="text-sm text-slate-600 mt-4 text-center">Have an account? <Link to="/login" className="underline">Log in</Link></p>
</div>
Expand Down
40 changes: 40 additions & 0 deletions tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading