diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458..84b32e6ff7 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -2,6 +2,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse from sqlmodel import col, delete, func, select from app import crud @@ -13,6 +14,7 @@ from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.models import ( + ConflictError, Item, Message, UpdatePassword, @@ -49,7 +51,15 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: @router.post( - "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, + responses={ + 409: { + "model": ConflictError, + "description": "Duplicate email or username", + } + }, ) def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: """ @@ -57,9 +67,9 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: """ user = crud.get_user_by_email(session=session, email=user_in.email) if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system.", + return JSONResponse( + status_code=409, + content={"error": "conflict", "field": "email", "message": "Already in use"}, ) user = crud.create_user(session=session, user_create=user_in) @@ -139,16 +149,25 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: return Message(message="User deleted successfully") -@router.post("/signup", response_model=UserPublic) +@router.post( + "/signup", + response_model=UserPublic, + responses={ + 409: { + "model": ConflictError, + "description": "Duplicate email or username", + } + }, +) def register_user(session: SessionDep, user_in: UserRegister) -> Any: """ Create new user without the need to be logged in. """ user = crud.get_user_by_email(session=session, email=user_in.email) if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system", + return JSONResponse( + status_code=409, + content={"error": "conflict", "field": "email", "message": "Already in use"}, ) user_create = UserCreate.model_validate(user_in) user = crud.create_user(session=session, user_create=user_create) diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..51d22f7c99 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,7 @@ import uuid +from typing import Literal + from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel @@ -111,3 +113,9 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +class ConflictError(SQLModel): + error: Literal["conflict"] + field: str + message: str diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py index 39e053e554..03d74b4dc0 100644 --- a/backend/tests/api/routes/test_users.py +++ b/backend/tests/api/routes/test_users.py @@ -127,9 +127,12 @@ def test_create_user_existing_username( headers=superuser_token_headers, json=data, ) - created_user = r.json() - assert r.status_code == 400 - assert "_id" not in created_user + assert r.status_code == 409 + assert r.json() == { + "error": "conflict", + "field": "email", + "message": "Already in use", + } def test_create_user_by_normal_user( @@ -316,8 +319,12 @@ def test_register_user_already_exists_error(client: TestClient) -> None: f"{settings.API_V1_STR}/users/signup", json=data, ) - assert r.status_code == 400 - assert r.json()["detail"] == "The user with this email already exists in the system" + assert r.status_code == 409 + assert r.json() == { + "error": "conflict", + "field": "email", + "message": "Already in use", + } def test_update_user( diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index 6e7890c485..7065ae5f9f 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -37,6 +37,7 @@ function SignUp() { register, handleSubmit, getValues, + setError, formState: { errors, isSubmitting }, } = useForm({ mode: "onBlur", @@ -49,8 +50,21 @@ function SignUp() { }, }) - const onSubmit: SubmitHandler = (data) => { - signUpMutation.mutate(data) + const onSubmit: SubmitHandler = async (data) => { + try { + await signUpMutation.mutateAsync(data) + } catch (err: any) { + const status = err?.status + const body = err?.body as any + if (status === 409 && body?.error === "conflict") { + const field = (body?.field as "email" | "full_name" | "password") || "email" + const message = body?.message || "Already in use" + setError(field as any, { type: "server", message }) + return + } + // rethrow to let the global onError handler show a toast + throw err + } } return ( diff --git a/frontend/tests/sign-up.spec.ts b/frontend/tests/sign-up.spec.ts index 750edb1830..e4bed998db 100644 --- a/frontend/tests/sign-up.spec.ts +++ b/frontend/tests/sign-up.spec.ts @@ -95,9 +95,8 @@ test("Sign up with existing email", async ({ page }) => { await fillForm(page, fullName, email, password, password) await page.getByRole("button", { name: "Sign Up" }).click() - await page - .getByText("The user with this email already exists in the system") - .click() + // Expect inline error message + await expect(page.getByText("Already in use")).toBeVisible() }) test("Sign up with weak password", async ({ page }) => {