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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 05:00:33 +08:00
parent 8e226fee0f
commit 6130d09ae8
3 changed files with 55 additions and 10 deletions

View File

@ -668,6 +668,15 @@ async def update_profile(
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email is already in use") 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 # SEC-01: Explicit field assignment — only allowed profile fields
if "first_name" in update_data: if "first_name" in update_data:
current_user.first_name = update_data["first_name"] current_user.first_name = update_data["first_name"]
@ -677,6 +686,8 @@ async def update_profile(
current_user.email = update_data["email"] current_user.email = update_data["email"]
if "date_of_birth" in update_data: if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"] 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( await log_audit_event(
db, action="auth.profile_updated", actor_id=current_user.id, db, action="auth.profile_updated", actor_id=current_user.id,

View File

@ -172,6 +172,17 @@ class ProfileUpdate(BaseModel):
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 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") @field_validator("email")
@classmethod @classmethod

View File

@ -88,6 +88,8 @@ export default function SettingsPage() {
const [profileEmail, setProfileEmail] = useState(''); const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState(''); const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null); const [emailError, setEmailError] = useState<string | null>(null);
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (profileQuery.data) { if (profileQuery.data) {
@ -95,6 +97,7 @@ export default function SettingsPage() {
setLastName(profileQuery.data.last_name ?? ''); setLastName(profileQuery.data.last_name ?? '');
setProfileEmail(profileQuery.data.email ?? ''); setProfileEmail(profileQuery.data.email ?? '');
setDateOfBirth(profileQuery.data.date_of_birth ?? ''); setDateOfBirth(profileQuery.data.date_of_birth ?? '');
setUmbralName(profileQuery.data.umbral_name ?? '');
} }
}, [profileQuery.dataUpdatedAt]); }, [profileQuery.dataUpdatedAt]);
@ -207,8 +210,8 @@ export default function SettingsPage() {
} }
}; };
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
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;
@ -222,6 +225,15 @@ export default function SettingsPage() {
} }
setEmailError(null); 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 { try {
await api.put('/auth/profile', { [field]: current || null }); await api.put('/auth/profile', { [field]: current || null });
queryClient.invalidateQueries({ queryKey: ['profile'] }); queryClient.invalidateQueries({ queryKey: ['profile'] });
@ -230,6 +242,8 @@ export default function SettingsPage() {
const detail = err?.response?.data?.detail; const detail = err?.response?.data?.detail;
if (field === 'email' && detail) { if (field === 'email' && detail) {
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); 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 { } else {
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
} }
@ -730,18 +744,27 @@ export default function SettingsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Umbral Name</Label> <Label htmlFor="umbral_name">Umbral Name</Label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Input <Input
value={profileQuery.data?.umbral_name ?? ''} id="umbral_name"
disabled value={umbralName}
className="opacity-70 cursor-not-allowed" onChange={(e) => { 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' : ''}
/> />
<CopyableField value={profileQuery.data?.umbral_name ?? ''} label="Umbral name" /> <CopyableField value={umbralName} label="Umbral name" />
</div> </div>
{umbralNameError ? (
<p className="text-xs text-red-400">{umbralNameError}</p>
) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
How other Umbra users find you How other Umbra users find you
</p> </p>
)}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">