From 6130d09ae846cb1e924b0cbddeb260618c2c682e Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 05:00:33 +0800 Subject: [PATCH] Make umbral name editable in user settings - Add umbral_name to ProfileUpdate schema with regex validation - Add uniqueness check in PUT /auth/profile handler - Replace disabled input with editable save-on-blur field in Social card - Client-side validation (3-50 chars, alphanumeric/hyphens/underscores) - Inline error display for validation failures and taken names Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 11 +++++ backend/app/schemas/auth.py | 11 +++++ .../src/components/settings/SettingsPage.tsx | 43 ++++++++++++++----- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a1dd542..552fe69 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -668,6 +668,15 @@ async def update_profile( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email is already in use") + # Umbral name uniqueness check if changing + if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name: + new_name = update_data["umbral_name"] + existing = await db.execute( + select(User).where(User.umbral_name == new_name, User.id != current_user.id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Umbral name is already taken") + # SEC-01: Explicit field assignment — only allowed profile fields if "first_name" in update_data: current_user.first_name = update_data["first_name"] @@ -677,6 +686,8 @@ async def update_profile( current_user.email = update_data["email"] if "date_of_birth" in update_data: current_user.date_of_birth = update_data["date_of_birth"] + if "umbral_name" in update_data: + current_user.umbral_name = update_data["umbral_name"] await log_audit_event( db, action="auth.profile_updated", actor_id=current_user.id, diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 9b358a6..79dfc15 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -172,6 +172,17 @@ class ProfileUpdate(BaseModel): last_name: str | None = Field(None, max_length=100) email: str | None = Field(None, max_length=254) date_of_birth: date | None = None + umbral_name: str | None = Field(None, min_length=3, max_length=50) + + @field_validator("umbral_name") + @classmethod + def validate_umbral_name(cls, v: str | None) -> str | None: + if v is None: + return v + import re + if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', v): + raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores') + return v @field_validator("email") @classmethod diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 0966363..607de0b 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -88,6 +88,8 @@ export default function SettingsPage() { const [profileEmail, setProfileEmail] = useState(''); const [dateOfBirth, setDateOfBirth] = useState(''); const [emailError, setEmailError] = useState(null); + const [umbralName, setUmbralName] = useState(''); + const [umbralNameError, setUmbralNameError] = useState(null); useEffect(() => { if (profileQuery.data) { @@ -95,6 +97,7 @@ export default function SettingsPage() { setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); setDateOfBirth(profileQuery.data.date_of_birth ?? ''); + setUmbralName(profileQuery.data.umbral_name ?? ''); } }, [profileQuery.dataUpdatedAt]); @@ -207,8 +210,8 @@ export default function SettingsPage() { } }; - 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 handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName }; const current = values[field].trim(); const original = profileQuery.data?.[field] ?? ''; if (current === (original || '')) return; @@ -222,6 +225,15 @@ export default function SettingsPage() { } setEmailError(null); + // Client-side umbral name validation + if (field === 'umbral_name') { + if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) { + setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores'); + return; + } + setUmbralNameError(null); + } + try { await api.put('/auth/profile', { [field]: current || null }); queryClient.invalidateQueries({ queryKey: ['profile'] }); @@ -230,6 +242,8 @@ export default function SettingsPage() { const detail = err?.response?.data?.detail; if (field === 'email' && detail) { setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); + } else if (field === 'umbral_name' && detail) { + setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name'); } else { toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); } @@ -730,18 +744,27 @@ export default function SettingsPage() {
- +
{ setUmbralName(e.target.value); setUmbralNameError(null); }} + onBlur={() => handleProfileSave('umbral_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }} + maxLength={50} + placeholder="Your discoverable name" + className={umbralNameError ? 'border-red-500/50' : ''} /> - +
-

- How other Umbra users find you -

+ {umbralNameError ? ( +

{umbralNameError}

+ ) : ( +

+ How other Umbra users find you +

+ )}