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():
|
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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user