From 45f3788fb0b5cf61f10e1ec7c03c244e5f09922d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:02:42 +0800 Subject: [PATCH] Add preferred name + email to registration, profile card to settings Registration form now collects optional preferred_name and email fields. Settings page Profile card expanded with first name, last name, and email (editable via new GET/PUT /api/auth/profile endpoints). Email uniqueness enforced on both registration and profile update. No migrations needed. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 48 +++++++- backend/app/schemas/auth.py | 69 +++++++++++- frontend/src/components/auth/LockScreen.tsx | 39 ++++++- .../src/components/settings/SettingsPage.tsx | 103 +++++++++++++++++- frontend/src/hooks/useAuth.ts | 9 +- frontend/src/types/index.ts | 7 ++ 6 files changed, 266 insertions(+), 9 deletions(-) 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" /> +
+ + setRegPreferredName(e.target.value)} + placeholder="What should we call you?" + maxLength={100} + autoComplete="given-name" + /> +
+
+ + setRegEmail(e.target.value)} + placeholder="your@email.com" + maxLength={254} + autoComplete="email" + /> +
(settings?.auto_lock_minutes ?? 5); + // Profile fields (stored on User model, fetched from /auth/profile) + const profileQuery = useQuery({ + queryKey: ['profile'], + queryFn: async () => { + const { data } = await api.get('/auth/profile'); + return data; + }, + }); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [profileEmail, setProfileEmail] = useState(''); + const [emailError, setEmailError] = useState(null); + + useEffect(() => { + if (profileQuery.data) { + setFirstName(profileQuery.data.first_name ?? ''); + setLastName(profileQuery.data.last_name ?? ''); + setProfileEmail(profileQuery.data.email ?? ''); + } + }, [profileQuery.data?.username]); + // Sync state when settings load useEffect(() => { if (settings) { @@ -149,6 +170,35 @@ 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 current = values[field].trim(); + const original = profileQuery.data?.[field] ?? ''; + if (current === (original || '')) return; + + // Client-side email validation + if (field === 'email' && current) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) { + setEmailError('Invalid email format'); + return; + } + } + setEmailError(null); + + try { + await api.put('/auth/profile', { [field]: current || null }); + queryClient.invalidateQueries({ queryKey: ['profile'] }); + toast.success('Profile updated'); + } catch (err: any) { + const detail = err?.response?.data?.detail; + if (field === 'email' && detail) { + setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); + } else { + toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); + } + } + }; + const handleColorChange = async (color: string) => { setSelectedColor(color); try { @@ -233,11 +283,11 @@ export default function SettingsPage() {
Profile - Personalize how UMBRA greets you + Your profile and display preferences
- +
+
+
+ + setFirstName(e.target.value)} + onBlur={() => handleProfileSave('first_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }} + maxLength={100} + /> +
+
+ + setLastName(e.target.value)} + onBlur={() => handleProfileSave('last_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }} + maxLength={100} + /> +
+
+
+ + { setProfileEmail(e.target.value); setEmailError(null); }} + onBlur={() => handleProfileSave('email')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('email'); }} + maxLength={254} + className={emailError ? 'border-red-500/50' : ''} + /> + {emailError && ( +

{emailError}

+ )} +
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 7c181f5..f287c5c 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -47,8 +47,13 @@ export function useAuth() { }); const registerMutation = useMutation({ - mutationFn: async ({ username, password }: { username: string; password: string }) => { - const { data } = await api.post('/auth/register', { username, password }); + mutationFn: async ({ username, password, email, preferred_name }: { + username: string; password: string; email?: string; preferred_name?: string; + }) => { + const payload: Record = { username, password }; + if (email) payload.email = email; + if (preferred_name) payload.preferred_name = preferred_name; + const { data } = await api.post('/auth/register', payload); return data; }, onSuccess: (data) => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fed8531..659479b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -345,6 +345,13 @@ export interface UpcomingResponse { cutoff_date: string; } +export interface UserProfile { + username: string; + email: string | null; + first_name: string | null; + last_name: string | null; +} + export interface EventTemplate { id: number; name: string;