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 <noreply@anthropic.com>
This commit is contained in:
parent
3e39c709b7
commit
45f3788fb0
@ -33,6 +33,7 @@ from app.models.calendar import Calendar
|
|||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
SetupRequest, LoginRequest, RegisterRequest,
|
SetupRequest, LoginRequest, RegisterRequest,
|
||||||
ChangePasswordRequest, VerifyPasswordRequest,
|
ChangePasswordRequest, VerifyPasswordRequest,
|
||||||
|
ProfileUpdate, ProfileResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
hash_password,
|
hash_password,
|
||||||
@ -441,12 +442,21 @@ async def register(
|
|||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
|
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)
|
password_hash = hash_password(data.password)
|
||||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role="standard",
|
role="standard",
|
||||||
|
email=data.email,
|
||||||
last_password_change_at=datetime.now(),
|
last_password_change_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -457,7 +467,7 @@ async def register(
|
|||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
await db.flush()
|
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)
|
ip = get_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
@ -622,3 +632,39 @@ async def change_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Password changed successfully"}
|
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)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
def _validate_password_strength(v: str) -> str:
|
def _validate_password_strength(v: str) -> str:
|
||||||
@ -58,6 +58,8 @@ class RegisterRequest(BaseModel):
|
|||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
email: str | None = Field(None, max_length=254)
|
||||||
|
preferred_name: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
@field_validator("username")
|
@field_validator("username")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -69,6 +71,30 @@ class RegisterRequest(BaseModel):
|
|||||||
def validate_password(cls, v: str) -> str:
|
def validate_password(cls, v: str) -> str:
|
||||||
return _validate_password_strength(v)
|
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):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@ -106,3 +132,44 @@ class VerifyPasswordRequest(BaseModel):
|
|||||||
if len(v) > 128:
|
if len(v) > 128:
|
||||||
raise ValueError("Password must be 128 characters or fewer")
|
raise ValueError("Password must be 128 characters or fewer")
|
||||||
return v
|
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
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export default function LockScreen() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// ── Registration optional fields ──
|
||||||
|
const [regEmail, setRegEmail] = useState('');
|
||||||
|
const [regPreferredName, setRegPreferredName] = useState('');
|
||||||
|
|
||||||
// ── TOTP challenge ──
|
// ── TOTP challenge ──
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
@ -137,7 +141,12 @@ export default function LockScreen() {
|
|||||||
if (err) { toast.error(err); return; }
|
if (err) { toast.error(err); return; }
|
||||||
if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
|
if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
|
||||||
try {
|
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
|
// On success useAuth invalidates query → Navigate handles redirect
|
||||||
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
|
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -557,6 +566,8 @@ export default function LockScreen() {
|
|||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setRegEmail('');
|
||||||
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@ -598,6 +609,30 @@ export default function LockScreen() {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reg-preferred-name">Preferred Name</Label>
|
||||||
|
<Input
|
||||||
|
id="reg-preferred-name"
|
||||||
|
type="text"
|
||||||
|
value={regPreferredName}
|
||||||
|
onChange={(e) => setRegPreferredName(e.target.value)}
|
||||||
|
placeholder="What should we call you?"
|
||||||
|
maxLength={100}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reg-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="reg-email"
|
||||||
|
type="email"
|
||||||
|
value={regEmail}
|
||||||
|
onChange={(e) => setRegEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
maxLength={254}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reg-password" required>Password</Label>
|
<Label htmlFor="reg-password" required>Password</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -641,6 +676,8 @@ export default function LockScreen() {
|
|||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setRegEmail('');
|
||||||
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
@ -21,7 +21,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation } from '@/types';
|
import type { GeoLocation, UserProfile } from '@/types';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import NtfySettingsSection from './NtfySettingsSection';
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
@ -54,6 +54,27 @@ export default function SettingsPage() {
|
|||||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(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<UserProfile>('/auth/profile');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [profileEmail, setProfileEmail] = useState('');
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(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
|
// Sync state when settings load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@ -149,6 +170,35 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email') => {
|
||||||
|
const values: Record<string, string> = { 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) => {
|
const handleColorChange = async (color: string) => {
|
||||||
setSelectedColor(color);
|
setSelectedColor(color);
|
||||||
try {
|
try {
|
||||||
@ -233,11 +283,11 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Profile</CardTitle>
|
<CardTitle>Profile</CardTitle>
|
||||||
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
<CardDescription>Your profile and display preferences</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preferred_name">Preferred Name</Label>
|
<Label htmlFor="preferred_name">Preferred Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -254,6 +304,51 @@ export default function SettingsPage() {
|
|||||||
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="first_name">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="first_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="First name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
onBlur={() => handleProfileSave('first_name')}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="last_name">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="last_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Last name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
onBlur={() => handleProfileSave('last_name')}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile_email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="profile_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={profileEmail}
|
||||||
|
onChange={(e) => { 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 && (
|
||||||
|
<p className="text-xs text-red-400">{emailError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -47,8 +47,13 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: async ({ username, password }: { username: string; password: string }) => {
|
mutationFn: async ({ username, password, email, preferred_name }: {
|
||||||
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', { username, password });
|
username: string; password: string; email?: string; preferred_name?: string;
|
||||||
|
}) => {
|
||||||
|
const payload: Record<string, string> = { username, password };
|
||||||
|
if (email) payload.email = email;
|
||||||
|
if (preferred_name) payload.preferred_name = preferred_name;
|
||||||
|
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
|||||||
@ -345,6 +345,13 @@ export interface UpcomingResponse {
|
|||||||
cutoff_date: string;
|
cutoff_date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventTemplate {
|
export interface EventTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user