diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b982e48..45239dc 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -33,6 +33,7 @@ from app.models.calendar import Calendar from app.schemas.auth import ( SetupRequest, LoginRequest, RegisterRequest, ChangePasswordRequest, VerifyPasswordRequest, + ProfileUpdate, ProfileResponse, ) from app.services.auth import ( hash_password, @@ -441,12 +442,21 @@ async def register( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + # Check email uniqueness (generic error to prevent enumeration) + if data.email: + existing_email = await db.execute( + select(User).where(User.email == data.email) + ) + if existing_email.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + password_hash = hash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, password_hash=password_hash, role="standard", + email=data.email, last_password_change_at=datetime.now(), ) @@ -457,7 +467,7 @@ async def register( db.add(new_user) await db.flush() - await _create_user_defaults(db, new_user.id) + await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name) ip = get_client_ip(request) user_agent = request.headers.get("user-agent") @@ -622,3 +632,39 @@ async def change_password( await db.commit() return {"message": "Password changed successfully"} + + +@router.get("/profile", response_model=ProfileResponse) +async def get_profile( + current_user: User = Depends(get_current_user), +): + """Return the current user's profile fields.""" + return ProfileResponse.model_validate(current_user) + + +@router.put("/profile", response_model=ProfileResponse) +async def update_profile( + data: ProfileUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user's profile fields (first_name, last_name, email).""" + update_data = data.model_dump(exclude_unset=True) + + # Email uniqueness check if email is changing + if "email" in update_data and update_data["email"] != current_user.email: + new_email = update_data["email"] + if new_email: + existing = await db.execute( + select(User).where(User.email == new_email, User.id != current_user.id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email is already in use") + + # SEC-01: Explicit field assignment + for key, value in update_data.items(): + setattr(current_user, key, value) + + await db.commit() + await db.refresh(current_user) + return ProfileResponse.model_validate(current_user) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index ad86a15..378e6e0 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator def _validate_password_strength(v: str) -> str: @@ -58,6 +58,8 @@ class RegisterRequest(BaseModel): username: str password: str + email: str | None = Field(None, max_length=254) + preferred_name: str | None = Field(None, max_length=100) @field_validator("username") @classmethod @@ -69,6 +71,30 @@ class RegisterRequest(BaseModel): def validate_password(cls, v: str) -> str: return _validate_password_strength(v) + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("preferred_name") + @classmethod + def validate_preferred_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -106,3 +132,44 @@ class VerifyPasswordRequest(BaseModel): if len(v) > 128: raise ValueError("Password must be 128 characters or fewer") return v + + +class ProfileUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: str | None = Field(None, max_length=254) + + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("first_name", "last_name") + @classmethod + def validate_name_fields(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + + +class ProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + username: str + email: str | None + first_name: str | None + last_name: str | None diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 95c9e5e..430f0ef 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -53,6 +53,10 @@ export default function LockScreen() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + // ── Registration optional fields ── + const [regEmail, setRegEmail] = useState(''); + const [regPreferredName, setRegPreferredName] = useState(''); + // ── TOTP challenge ── const [totpCode, setTotpCode] = useState(''); const [useBackupCode, setUseBackupCode] = useState(false); @@ -137,7 +141,12 @@ export default function LockScreen() { if (err) { toast.error(err); return; } if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } try { - await register({ username, password }); + await register({ + username, + password, + email: regEmail.trim() || undefined, + preferred_name: regPreferredName.trim() || undefined, + }); // On success useAuth invalidates query → Navigate handles redirect // If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches } catch (error) { @@ -557,6 +566,8 @@ export default function LockScreen() { setUsername(''); setPassword(''); setConfirmPassword(''); + setRegEmail(''); + setRegPreferredName(''); setLoginError(null); }} className="text-xs text-muted-foreground hover:text-foreground transition-colors" @@ -598,6 +609,30 @@ export default function LockScreen() { autoComplete="username" /> +
{emailError}
+ )} +