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:
parent
8e226fee0f
commit
6130d09ae8
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -88,6 +88,8 @@ export default function SettingsPage() {
|
||||
const [profileEmail, setProfileEmail] = useState('');
|
||||
const [dateOfBirth, setDateOfBirth] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [umbralNameError, setUmbralNameError] = useState<string | null>(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<string, string> = { 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<string, string> = { 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() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Umbral Name</Label>
|
||||
<Label htmlFor="umbral_name">Umbral Name</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={profileQuery.data?.umbral_name ?? ''}
|
||||
disabled
|
||||
className="opacity-70 cursor-not-allowed"
|
||||
id="umbral_name"
|
||||
value={umbralName}
|
||||
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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How other Umbra users find you
|
||||
</p>
|
||||
{umbralNameError ? (
|
||||
<p className="text-xs text-red-400">{umbralNameError}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How other Umbra users find you
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user