diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 63b2aa9..dc4ed25 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -9,7 +9,7 @@ const registerSchema = z.object({ handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), email: z.string().email(), password: z.string().min(8), - displayName: z.string().optional(), + displayName: z.string().trim().max(50).optional(), turnstileToken: z.string().nullable().optional(), }); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ca848f3..4f1141d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -474,6 +474,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp }}>@ setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} @@ -511,6 +513,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp setPassword(e.target.value)} @@ -529,6 +533,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp setDisplayName(e.target.value)} @@ -541,6 +547,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp setConfirmPassword(e.target.value)} @@ -558,6 +566,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp setEmail(e.target.value)} diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 8e8e271..9a435f6 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -31,6 +31,8 @@ interface UserSummary { isBot?: boolean; } +type ProfileMediaField = 'avatarUrl' | 'headerUrl'; + // Strip HTML tags from a string const stripHtml = (html: string | null | undefined): string | null => { if (!html) return null; @@ -79,7 +81,7 @@ export default function ProfilePage() { const params = useParams(); const router = useRouter(); const handle = (params.handle as string)?.replace(/^@/, '') || ''; - const { did, handle: currentHandle, isIdentityUnlocked, signUserAction } = useAuth(); + const { did, handle: currentHandle, isIdentityUnlocked, signUserAction, updateUserProfile } = useAuth(); const [user, setUser] = useState(null); const userFullHandle = user ? useFormattedHandle(user.handle) : ''; @@ -111,6 +113,7 @@ export default function ProfilePage() { }); const [saveError, setSaveError] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [mediaSaveStatus, setMediaSaveStatus] = useState>>({}); const [isBlocked, setIsBlocked] = useState(false); const [showMenu, setShowMenu] = useState(false); useEffect(() => { @@ -251,7 +254,7 @@ export default function ProfilePage() { }; useEffect(() => { - if (user && currentUser?.handle === user.handle) { + if (user && currentUser?.handle === user.handle && !isEditing) { setProfileForm({ displayName: user.displayName || '', bio: user.bio || '', @@ -260,7 +263,7 @@ export default function ProfilePage() { website: user.website || '', }); } - }, [user, currentUser]); + }, [user, currentUser, isEditing]); useEffect(() => { const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0]; @@ -378,6 +381,24 @@ export default function ProfilePage() { } }; + const updateProfile = async (changes: Partial): Promise => { + const signedPayload = await signUserAction('update_profile', changes); + + const res = await fetch('/api/auth/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedPayload), + }); + + const data = await res.json() as { error?: string; user?: User }; + + if (!res.ok || !data.user) { + throw new Error(data.error || 'Failed to update profile'); + } + + return data.user; + }; + const handleSaveProfile = async () => { if (!isOwnProfile) return; @@ -390,22 +411,10 @@ export default function ProfilePage() { setSaveError(null); try { - // Sign the profile update action - const signedPayload = await signUserAction('update_profile', profileForm); + const updatedUser = await updateProfile(profileForm); - const res = await fetch('/api/auth/me', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(signedPayload), - }); - - const data = await res.json(); - - if (!res.ok) { - throw new Error(data.error || 'Failed to update profile'); - } - - setUser(data.user); + setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser); + updateUserProfile(updatedUser); setIsEditing(false); } catch (error) { console.error('Profile update failed', error); @@ -415,6 +424,31 @@ export default function ProfilePage() { } }; + const handleProfileMediaChange = async (field: ProfileMediaField, value: string) => { + setProfileForm(prev => ({ ...prev, [field]: value })); + setSaveError(null); + + if (!isOwnProfile || !isIdentityUnlocked) { + setSaveError('Session expired. Please log in again.'); + return; + } + + setMediaSaveStatus(prev => ({ ...prev, [field]: 'saving' })); + try { + const updatedUser = await updateProfile({ [field]: value }); + setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser); + updateUserProfile(updatedUser); + setMediaSaveStatus(prev => ({ ...prev, [field]: 'saved' })); + window.setTimeout(() => { + setMediaSaveStatus(prev => prev[field] === 'saved' ? { ...prev, [field]: undefined } : prev); + }, 2000); + } catch (error) { + console.error('Profile media update failed', error); + setMediaSaveStatus(prev => ({ ...prev, [field]: undefined })); + setSaveError(error instanceof Error ? error.message : 'Unable to save profile media. Please try again.'); + } + }; + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('en-US', { month: 'long', @@ -785,26 +819,24 @@ export default function ProfilePage() { label="Avatar" value={profileForm.avatarUrl} onChange={(avatarUrl) => { - setSaveError(null); - setProfileForm({ ...profileForm, avatarUrl }); + void handleProfileMediaChange('avatarUrl', avatarUrl); }} previewWidth={48} previewHeight={48} previewBorderRadius="50%" - helperText="Square image recommended (optional)" + helperText={mediaSaveStatus.avatarUrl === 'saving' ? 'Saving avatar…' : mediaSaveStatus.avatarUrl === 'saved' ? 'Avatar saved' : 'Square image recommended (optional)'} onError={(message) => setSaveError(message || null)} /> { - setSaveError(null); - setProfileForm({ ...profileForm, headerUrl }); + void handleProfileMediaChange('headerUrl', headerUrl); }} previewWidth={120} previewHeight={40} previewBorderRadius="4px" - helperText="Wide image recommended, e.g. 1500x500 (optional)" + helperText={mediaSaveStatus.headerUrl === 'saving' ? 'Saving header…' : mediaSaveStatus.headerUrl === 'saved' ? 'Header saved' : 'Wide image recommended, e.g. 1500x500 (optional)'} onError={(message) => setSaveError(message || null)} /> {saveError && ( diff --git a/src/lib/auth/display-name.test.ts b/src/lib/auth/display-name.test.ts new file mode 100644 index 0000000..bfd0466 --- /dev/null +++ b/src/lib/auth/display-name.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { registrationDisplayName } from './display-name'; + +describe('registrationDisplayName', () => { + it('uses the handle when no display name was provided', () => { + expect(registrationDisplayName('alice', 'alice@example.com')).toBe('alice'); + }); + + it('does not expose an autofilled email as the public display name', () => { + expect(registrationDisplayName('alice', 'alice@example.com', ' Alice@Example.com ')).toBe('alice'); + }); + + it('trims and preserves an intentional display name', () => { + expect(registrationDisplayName('alice', 'alice@example.com', ' Alice Example ')).toBe('Alice Example'); + }); +}); diff --git a/src/lib/auth/display-name.ts b/src/lib/auth/display-name.ts new file mode 100644 index 0000000..2be218d --- /dev/null +++ b/src/lib/auth/display-name.ts @@ -0,0 +1,7 @@ +export function registrationDisplayName(handle: string, email: string, displayName?: string): string { + const trimmedDisplayName = displayName?.trim(); + if (!trimmedDisplayName || trimmedDisplayName.toLowerCase() === email.trim().toLowerCase()) { + return handle; + } + return trimmedDisplayName; +} diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 84a82ed..0772e77 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -11,6 +11,7 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k import { base58btc } from 'multiformats/bases/base58'; import { cookies } from 'next/headers'; import { upsertHandleEntries } from '@/lib/federation/handles'; +import { registrationDisplayName } from '@/lib/auth/display-name'; const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session'; const SESSION_COOKIE_NAME = 'synapsis_sessions'; @@ -321,7 +322,7 @@ export async function registerUser( handle: handle.toLowerCase(), email: email.toLowerCase(), passwordHash, - displayName: displayName || handle, + displayName: registrationDisplayName(handle, email, displayName), publicKey, privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), }).returning(); diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx index f3d6786..be832df 100644 --- a/src/lib/contexts/AuthContext.tsx +++ b/src/lib/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ export interface User { handle: string; displayName: string; email?: string; - avatarUrl?: string; + avatarUrl?: string | null; did?: string; publicKey?: string; privateKeyEncrypted?: string; @@ -34,6 +34,7 @@ interface AuthContextType { logout: (userId?: string) => Promise; switchAccount: (userId: string) => Promise; refreshAuth: () => Promise; + updateUserProfile: (updates: Partial) => void; lockIdentity: () => Promise; // New: manual lock signUserAction: (action: string, data: any) => Promise; requiresUnlock: boolean; // True if user has encrypted key but not unlocked @@ -57,6 +58,7 @@ const AuthContext = createContext({ logout: async () => { }, switchAccount: async () => { }, refreshAuth: async () => { }, + updateUserProfile: () => { }, lockIdentity: async () => { }, signUserAction: async () => Promise.reject('Not initialized'), requiresUnlock: false, @@ -205,6 +207,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [refreshAuth]); + const updateUserProfile = useCallback((updates: Partial) => { + setUser(current => current ? { ...current, ...updates } : current); + setAccounts(current => current.map(account => account.id === updates.id ? { ...account, ...updates } : account)); + }, []); + // Load auth state on mount useEffect(() => { refreshAuth(); @@ -231,6 +238,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { logout, switchAccount, refreshAuth, + updateUserProfile, lockIdentity, signUserAction, requiresUnlock,