Add required email + date of birth to registration, shared validators, partial index
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
02efe04fc4
commit
e8109cef6b
@ -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")
|
||||||
@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@ -9,9 +9,10 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, 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)
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
last_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)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
# MFA — populated in Track B
|
# MFA — populated in Track B
|
||||||
|
|||||||
@ -457,6 +457,7 @@ async def register(
|
|||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role="standard",
|
role="standard",
|
||||||
email=data.email,
|
email=data.email,
|
||||||
|
date_of_birth=data.date_of_birth,
|
||||||
last_password_change_at=datetime.now(),
|
last_password_change_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -649,7 +650,7 @@ async def update_profile(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if not update_data:
|
if not update_data:
|
||||||
@ -672,6 +673,8 @@ async def update_profile(
|
|||||||
current_user.last_name = update_data["last_name"]
|
current_user.last_name = update_data["last_name"]
|
||||||
if "email" in update_data:
|
if "email" in update_data:
|
||||||
current_user.email = update_data["email"]
|
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(
|
await log_audit_event(
|
||||||
db, action="auth.profile_updated", actor_id=current_user.id,
|
db, action="auth.profile_updated", actor_id=current_user.id,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from typing import Optional, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
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")
|
@field_validator("email")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_email(cls, v: str | None) -> str | None:
|
def validate_email(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_email_format(v)
|
||||||
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
|
|
||||||
|
|
||||||
@field_validator("first_name", "last_name", "preferred_name")
|
@field_validator("first_name", "last_name", "preferred_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_name_fields(cls, v: str | None) -> str | None:
|
def validate_name_fields(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_name_field(v)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserRoleRequest(BaseModel):
|
class UpdateUserRoleRequest(BaseModel):
|
||||||
|
|||||||
@ -1,6 +1,39 @@
|
|||||||
import re
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
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:
|
def _validate_password_strength(v: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -58,7 +91,8 @@ class RegisterRequest(BaseModel):
|
|||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: 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)
|
preferred_name: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
@field_validator("username")
|
@field_validator("username")
|
||||||
@ -73,27 +107,15 @@ class RegisterRequest(BaseModel):
|
|||||||
|
|
||||||
@field_validator("email")
|
@field_validator("email")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_email(cls, v: str | None) -> str | None:
|
def validate_email(cls, v: str) -> str:
|
||||||
if v is None:
|
result = _validate_email_format(v, required=True)
|
||||||
return None
|
assert result is not None # required=True guarantees non-None
|
||||||
v = v.strip().lower()
|
return result
|
||||||
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")
|
@field_validator("preferred_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_preferred_name(cls, v: str | None) -> str | None:
|
def validate_preferred_name(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_name_field(v)
|
||||||
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):
|
class LoginRequest(BaseModel):
|
||||||
@ -140,30 +162,17 @@ class ProfileUpdate(BaseModel):
|
|||||||
first_name: str | None = Field(None, max_length=100)
|
first_name: str | None = Field(None, max_length=100)
|
||||||
last_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)
|
email: str | None = Field(None, max_length=254)
|
||||||
|
date_of_birth: date | None = None
|
||||||
|
|
||||||
@field_validator("email")
|
@field_validator("email")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_email(cls, v: str | None) -> str | None:
|
def validate_email(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_email_format(v)
|
||||||
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")
|
@field_validator("first_name", "last_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_name_fields(cls, v: str | None) -> str | None:
|
def validate_name_fields(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_name_field(v)
|
||||||
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):
|
class ProfileResponse(BaseModel):
|
||||||
@ -173,3 +182,4 @@ class ProfileResponse(BaseModel):
|
|||||||
email: str | None
|
email: str | None
|
||||||
first_name: str | None
|
first_name: str | None
|
||||||
last_name: str | None
|
last_name: str | None
|
||||||
|
date_of_birth: date | None
|
||||||
|
|||||||
@ -53,8 +53,9 @@ export default function LockScreen() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
// ── Registration optional fields ──
|
// ── Registration fields ──
|
||||||
const [regEmail, setRegEmail] = useState('');
|
const [regEmail, setRegEmail] = useState('');
|
||||||
|
const [regDateOfBirth, setRegDateOfBirth] = useState('');
|
||||||
const [regPreferredName, setRegPreferredName] = useState('');
|
const [regPreferredName, setRegPreferredName] = useState('');
|
||||||
|
|
||||||
// ── TOTP challenge ──
|
// ── TOTP challenge ──
|
||||||
@ -140,11 +141,14 @@ export default function LockScreen() {
|
|||||||
const err = validatePassword(password);
|
const err = validatePassword(password);
|
||||||
if (err) { toast.error(err); return; }
|
if (err) { toast.error(err); return; }
|
||||||
if (password !== confirmPassword) { toast.error('Passwords do not match'); 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 {
|
try {
|
||||||
await register({
|
await register({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
email: regEmail.trim() || undefined,
|
email: regEmail.trim(),
|
||||||
|
date_of_birth: regDateOfBirth,
|
||||||
preferred_name: regPreferredName.trim() || undefined,
|
preferred_name: regPreferredName.trim() || undefined,
|
||||||
});
|
});
|
||||||
// On success useAuth invalidates query → Navigate handles redirect
|
// On success useAuth invalidates query → Navigate handles redirect
|
||||||
@ -567,6 +571,7 @@ export default function LockScreen() {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setRegEmail('');
|
setRegEmail('');
|
||||||
|
setRegDateOfBirth('');
|
||||||
setRegPreferredName('');
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
@ -622,17 +627,29 @@ export default function LockScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reg-email">Email</Label>
|
<Label htmlFor="reg-email" required>Email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="reg-email"
|
id="reg-email"
|
||||||
type="email"
|
type="email"
|
||||||
value={regEmail}
|
value={regEmail}
|
||||||
onChange={(e) => setRegEmail(e.target.value)}
|
onChange={(e) => setRegEmail(e.target.value)}
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reg-dob" required>Date of Birth</Label>
|
||||||
|
<Input
|
||||||
|
id="reg-dob"
|
||||||
|
type="date"
|
||||||
|
value={regDateOfBirth}
|
||||||
|
onChange={(e) => setRegDateOfBirth(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="bday"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reg-password" required>Password</Label>
|
<Label htmlFor="reg-password" required>Password</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -677,6 +694,7 @@ export default function LockScreen() {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setRegEmail('');
|
setRegEmail('');
|
||||||
|
setRegDateOfBirth('');
|
||||||
setRegPreferredName('');
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export default function SettingsPage() {
|
|||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState('');
|
const [lastName, setLastName] = useState('');
|
||||||
const [profileEmail, setProfileEmail] = useState('');
|
const [profileEmail, setProfileEmail] = useState('');
|
||||||
|
const [dateOfBirth, setDateOfBirth] = useState('');
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -72,6 +73,7 @@ export default function SettingsPage() {
|
|||||||
setFirstName(profileQuery.data.first_name ?? '');
|
setFirstName(profileQuery.data.first_name ?? '');
|
||||||
setLastName(profileQuery.data.last_name ?? '');
|
setLastName(profileQuery.data.last_name ?? '');
|
||||||
setProfileEmail(profileQuery.data.email ?? '');
|
setProfileEmail(profileQuery.data.email ?? '');
|
||||||
|
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
|
||||||
}
|
}
|
||||||
}, [profileQuery.dataUpdatedAt]);
|
}, [profileQuery.dataUpdatedAt]);
|
||||||
|
|
||||||
@ -170,8 +172,8 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email') => {
|
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
|
||||||
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail };
|
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
|
||||||
const current = values[field].trim();
|
const current = values[field].trim();
|
||||||
const original = profileQuery.data?.[field] ?? '';
|
const original = profileQuery.data?.[field] ?? '';
|
||||||
if (current === (original || '')) return;
|
if (current === (original || '')) return;
|
||||||
@ -349,6 +351,17 @@ export default function SettingsPage() {
|
|||||||
<p className="text-xs text-red-400">{emailError}</p>
|
<p className="text-xs text-red-400">{emailError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date_of_birth">Date of Birth</Label>
|
||||||
|
<Input
|
||||||
|
id="date_of_birth"
|
||||||
|
type="date"
|
||||||
|
value={dateOfBirth}
|
||||||
|
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||||
|
onBlur={() => handleProfileSave('date_of_birth')}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -47,11 +47,11 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: async ({ username, password, email, preferred_name }: {
|
mutationFn: async ({ username, password, email, date_of_birth, preferred_name }: {
|
||||||
username: string; password: string; email?: string; preferred_name?: string;
|
username: string; password: string; email: string; date_of_birth: string;
|
||||||
|
preferred_name?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const payload: Record<string, string> = { username, password };
|
const payload: Record<string, string> = { username, password, email, date_of_birth };
|
||||||
if (email) payload.email = email;
|
|
||||||
if (preferred_name) payload.preferred_name = preferred_name;
|
if (preferred_name) payload.preferred_name = preferred_name;
|
||||||
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', payload);
|
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', payload);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@ -350,6 +350,7 @@ export interface UserProfile {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
|
date_of_birth: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventTemplate {
|
export interface EventTemplate {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user