From e8109cef6bd0dd1ed4435a76a04325c21a0bd8a0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:21:11 +0800 Subject: [PATCH] Add required email + date of birth to registration, shared validators, partial index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S-01: Extract _EMAIL_REGEX, _validate_email_format, _validate_name_field shared helpers in schemas/auth.py — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest (eliminates 3x duplicated regex) - S-04: Migration 038 replaces plain unique constraint on email with a partial unique index WHERE email IS NOT NULL - Email is now required on registration (was optional) - Date of birth is now required on registration, editable in settings - User model gains date_of_birth (Date, nullable) column - ProfileUpdate/ProfileResponse include date_of_birth - Registration form adds required Email, Date of Birth fields - Settings Profile card adds Date of Birth input (save-on-blur) Co-Authored-By: Claude Opus 4.6 --- ...d_date_of_birth_and_email_partial_index.py | 34 ++++++++ backend/app/models/user.py | 7 +- backend/app/routers/auth.py | 5 +- backend/app/schemas/admin.py | 22 +----- backend/app/schemas/auth.py | 78 +++++++++++-------- frontend/src/components/auth/LockScreen.tsx | 24 +++++- .../src/components/settings/SettingsPage.tsx | 17 +++- frontend/src/hooks/useAuth.ts | 8 +- frontend/src/types/index.ts | 1 + 9 files changed, 130 insertions(+), 66 deletions(-) create mode 100644 backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py 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() { />
- + setRegEmail(e.target.value)} placeholder="your@email.com" + required maxLength={254} autoComplete="email" />
+
+ + setRegDateOfBirth(e.target.value)} + required + autoComplete="bday" + /> +
(null); useEffect(() => { @@ -72,6 +73,7 @@ export default function SettingsPage() { setFirstName(profileQuery.data.first_name ?? ''); setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); + setDateOfBirth(profileQuery.data.date_of_birth ?? ''); } }, [profileQuery.dataUpdatedAt]); @@ -170,8 +172,8 @@ export default function SettingsPage() { } }; - const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email') => { - const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail }; + const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; const current = values[field].trim(); const original = profileQuery.data?.[field] ?? ''; if (current === (original || '')) return; @@ -349,6 +351,17 @@ export default function SettingsPage() {

{emailError}

)}
+
+ + setDateOfBirth(e.target.value)} + onBlur={() => handleProfileSave('date_of_birth')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} + /> +
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index f287c5c..a7acc78 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -47,11 +47,11 @@ export function useAuth() { }); const registerMutation = useMutation({ - mutationFn: async ({ username, password, email, preferred_name }: { - username: string; password: string; email?: string; preferred_name?: string; + mutationFn: async ({ username, password, email, date_of_birth, preferred_name }: { + username: string; password: string; email: string; date_of_birth: string; + preferred_name?: string; }) => { - const payload: Record = { username, password }; - if (email) payload.email = email; + const payload: Record = { username, password, email, date_of_birth }; if (preferred_name) payload.preferred_name = preferred_name; const { data } = await api.post('/auth/register', payload); return data; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 659479b..378962d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -350,6 +350,7 @@ export interface UserProfile { email: string | null; first_name: string | null; last_name: string | null; + date_of_birth: string | null; } export interface EventTemplate {