diff --git a/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py b/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py new file mode 100644 index 0000000..7586cfe --- /dev/null +++ b/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py @@ -0,0 +1,34 @@ +"""Add date_of_birth column and partial unique index on email. + +Revision ID: 038 +Revises: 037 +""" +from alembic import op +import sqlalchemy as sa + +revision = "038" +down_revision = "037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add date_of_birth column (nullable — existing users won't have one) + op.add_column("users", sa.Column("date_of_birth", sa.Date, nullable=True)) + + # S-04: Replace plain unique constraint with partial unique index + # so multiple NULLs are allowed explicitly (PostgreSQL already allows this + # with a plain unique constraint, but a partial index is more intentional + # and performs better for email lookups on non-NULL values). + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_index("ix_users_email", table_name="users") + op.execute( + 'CREATE UNIQUE INDEX "ix_users_email_unique" ON users (email) WHERE email IS NOT NULL' + ) + + +def downgrade() -> None: + op.execute('DROP INDEX IF EXISTS "ix_users_email_unique"') + op.create_index("ix_users_email", "users", ["email"]) + op.create_unique_constraint("uq_users_email", "users", ["email"]) + op.drop_column("users", "date_of_birth") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 92623dd..cc9a181 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ -from sqlalchemy import String, Boolean, Integer, func +from sqlalchemy import String, Boolean, Integer, Date, func from sqlalchemy.orm import Mapped, mapped_column -from datetime import datetime +from datetime import datetime, date from app.database import Base @@ -9,9 +9,10 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) - email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + date_of_birth: Mapped[date | None] = mapped_column(Date, nullable=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) # MFA — populated in Track B diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f16c1b3..85a854e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -457,6 +457,7 @@ async def register( password_hash=password_hash, role="standard", email=data.email, + date_of_birth=data.date_of_birth, last_password_change_at=datetime.now(), ) @@ -649,7 +650,7 @@ async def update_profile( 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 the current user's profile fields (first_name, last_name, email, date_of_birth).""" update_data = data.model_dump(exclude_unset=True) if not update_data: @@ -672,6 +673,8 @@ async def update_profile( current_user.last_name = update_data["last_name"] if "email" in update_data: current_user.email = update_data["email"] + if "date_of_birth" in update_data: + current_user.date_of_birth = update_data["date_of_birth"] await log_audit_event( db, action="auth.profile_updated", actor_id=current_user.id, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index c24160d..ca0c781 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -10,7 +10,7 @@ from typing import Optional, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.schemas.auth import _validate_username, _validate_password_strength +from app.schemas.auth import _validate_username, _validate_password_strength, _validate_email_format, _validate_name_field # --------------------------------------------------------------------------- @@ -75,28 +75,12 @@ class CreateUserRequest(BaseModel): @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 - # Basic format check: must have exactly one @, with non-empty local and domain parts - if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): - raise ValueError("Invalid email format") - return v + return _validate_email_format(v) @field_validator("first_name", "last_name", "preferred_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 - # Reject ASCII control characters - if re.search(r"[\x00-\x1f]", v): - raise ValueError("Name must not contain control characters") - return v + return _validate_name_field(v) class UpdateUserRoleRequest(BaseModel): diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 378e6e0..2675141 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,6 +1,39 @@ import re +from datetime import date + from pydantic import BaseModel, ConfigDict, Field, field_validator +# Shared email format regex — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest +_EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + + +def _validate_email_format(v: str | None, *, required: bool = False) -> str | None: + """Shared email validation. Returns normalised email or None.""" + if v is None: + if required: + raise ValueError("Email is required") + return None + v = v.strip().lower() + if not v: + if required: + raise ValueError("Email is required") + return None + if not _EMAIL_REGEX.match(v): + raise ValueError("Invalid email format") + return v + + +def _validate_name_field(v: str | None) -> str | None: + """Shared name field validation (strips, rejects control chars).""" + 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 + def _validate_password_strength(v: str) -> str: """ @@ -58,7 +91,8 @@ class RegisterRequest(BaseModel): username: str password: str - email: str | None = Field(None, max_length=254) + email: str = Field(..., max_length=254) + date_of_birth: date preferred_name: str | None = Field(None, max_length=100) @field_validator("username") @@ -73,27 +107,15 @@ class RegisterRequest(BaseModel): @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 + def validate_email(cls, v: str) -> str: + result = _validate_email_format(v, required=True) + assert result is not None # required=True guarantees non-None + return result @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 + return _validate_name_field(v) class LoginRequest(BaseModel): @@ -140,30 +162,17 @@ class ProfileUpdate(BaseModel): 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) + date_of_birth: date | None = None @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 + return _validate_email_format(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 + return _validate_name_field(v) class ProfileResponse(BaseModel): @@ -173,3 +182,4 @@ class ProfileResponse(BaseModel): email: str | None first_name: str | None last_name: str | None + date_of_birth: date | None diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 430f0ef..6235e92 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -53,8 +53,9 @@ export default function LockScreen() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // ── Registration optional fields ── + // ── Registration fields ── const [regEmail, setRegEmail] = useState(''); + const [regDateOfBirth, setRegDateOfBirth] = useState(''); const [regPreferredName, setRegPreferredName] = useState(''); // ── TOTP challenge ── @@ -140,11 +141,14 @@ export default function LockScreen() { const err = validatePassword(password); if (err) { toast.error(err); return; } if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } + if (!regEmail.trim()) { toast.error('Email is required'); return; } + if (!regDateOfBirth) { toast.error('Date of birth is required'); return; } try { await register({ username, password, - email: regEmail.trim() || undefined, + email: regEmail.trim(), + date_of_birth: regDateOfBirth, preferred_name: regPreferredName.trim() || undefined, }); // On success useAuth invalidates query → Navigate handles redirect @@ -567,6 +571,7 @@ export default function LockScreen() { setPassword(''); setConfirmPassword(''); setRegEmail(''); + setRegDateOfBirth(''); setRegPreferredName(''); setLoginError(null); }} @@ -622,17 +627,29 @@ export default function LockScreen() { />
{emailError}
)}