diff --git a/frontend/tests/e2e/profile-update.spec.ts b/frontend/tests/e2e/profile-update.spec.ts new file mode 100644 index 0000000..9c360d8 --- /dev/null +++ b/frontend/tests/e2e/profile-update.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for user profile management: + * - Update name/email via PUT /auth/me + * - Delete (deactivate) account via DELETE /auth/me + */ + +test.describe('User Profile Management', () => { + const email = `test-${Date.now()}@example.com`; + const password = 'TestPass123!'; + + test.beforeAll(async ({ page }) => { + // Register a new user first + await page.goto('/register'); + await page.fill('input[placeholder="Full name"]', 'John Doe'); + await page.fill('input[placeholder="name@example.com"]', email); + await page.fill('input[placeholder="Password"]', password); + await page.click('button:has-text("Sign up")'); + await expect(page).toHaveURL('/') + }); + + test('update profile information', async ({ page }) => { + // Open settings profile page + await page.goto('/settings/profile'); + await page.fill('input[name="first_name"]', 'Jane'); + await page.fill('input[name="last_name"]', 'Smith'); + await page.fill('input[name="email"]', `jane.${Date.now()}@example.com`); + await page.click('button:has-text("Save Changes")'); + await expect(page.locator('p')).toContainText('Jane Smith'); + }); + + test('delete account (soft‑delete)', async ({ page }) => { + await page.goto('/settings/account'); + await page.click('button:has-text("Delete Account")'); + // Confirm modal (Playwright handles native confirm) + page.on('dialog', dialog => dialog.accept()); + await expect(page).toHaveURL('/login'); + // Verify login fails after deletion + await page.fill('input[placeholder="name@example.com"]', email); + await page.fill('input[placeholder="Password"]', password); + await page.click('button:has-text("Sign in")'); + await expect(page.locator('div[role="alert"]')).toContainText('Invalid email or password'); + }); +}); diff --git a/src/api/v1/auth.py b/src/api/v1/auth.py index 3a56237..ca64fa0 100644 --- a/src/api/v1/auth.py +++ b/src/api/v1/auth.py @@ -246,6 +246,74 @@ async def get_me( """ return current_user +# --------------------------------------------------- +# Update profile (PUT /auth/me) +# --------------------------------------------------- +@router.put( + "/me", + response_model=UserResponse, + status_code=status.HTTP_200_OK, +) +async def update_me( + update_data: UserUpdate, + current_user: Annotated[UserResponse, Depends(get_current_user)], + session: AsyncSession = Depends(get_db), +): + """Update current user profile (first_name, last_name, email).""" + # fetch full user record + from uuid import UUID + user = await get_user_by_id(session, UUID(current_user.id)) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if update_data.first_name is not None: + user.first_name = update_data.first_name + if update_data.last_name is not None: + user.last_name = update_data.last_name + if update_data.email is not None: + # ensure email is unique + from sqlalchemy import select + result = await session.execute(select(User).where(User.email == update_data.email, User.id != user.id)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already in use") + user.email = update_data.email + + session.add(user) + await session.commit() + await session.refresh(user) + return UserResponse.model_validate(user) + +# --------------------------------------------------- +# Delete account (DELETE /auth/me) +# --------------------------------------------------- +@router.delete( + "/me", + status_code=status.HTTP_200_OK, +) +async def delete_me( + current_user: Annotated[UserResponse, Depends(get_current_user)], + session: AsyncSession = Depends(get_db), +): + """Soft‑delete (deactivate) the current user account and revoke API keys.""" + from uuid import UUID + user = await get_user_by_id(session, UUID(current_user.id)) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # deactivate user + user.is_active = False + session.add(user) + # revoke all API keys (if any) + try: + from src.models.api_key import APIKey + from sqlalchemy import update + await session.execute(update(APIKey).where(APIKey.user_id == user.id).values(active=False)) + except Exception: + pass # ignore if APIKey model not present + await session.commit() + return {"message": "Account deactivated successfully"} + + @router.post( "/change-password", diff --git a/src/models/user.py b/src/models/user.py index 07b637c..cb69145 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -16,7 +16,8 @@ class User(Base, TimestampMixin): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email = Column(String(255), nullable=False, unique=True) password_hash = Column(String(255), nullable=False) - full_name = Column(String(255), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) is_active = Column(Boolean, default=True, nullable=False) is_superuser = Column(Boolean, default=False, nullable=False) last_login = Column(DateTime(timezone=True), nullable=True) diff --git a/src/schemas/user.py b/src/schemas/user.py index b6ff19c..099fc66 100644 --- a/src/schemas/user.py +++ b/src/schemas/user.py @@ -10,7 +10,8 @@ class UserBase(BaseModel): """Base user schema.""" email: EmailStr - full_name: Optional[str] = Field(None, max_length=255) + first_name: Optional[str] = Field(None, max_length=255) + last_name: Optional[str] = Field(None, max_length=255) class UserCreate(UserBase): @@ -22,7 +23,8 @@ class UserCreate(UserBase): class UserUpdate(BaseModel): """Schema for updating a user.""" - full_name: Optional[str] = Field(None, max_length=255) + first_name: Optional[str] = Field(None, max_length=255) + last_name: Optional[str] = Field(None, max_length=255) email: Optional[EmailStr] = Field(None, max_length=255)