Prevent email autofill as display name and autosave profile media

Hop-State: A_06FP8EYP9H40CXQH16XPSN8
Hop-Proposal: R_06FP8EY0KW03D22K71DN4KG
Hop-Task: T_06FP8DNXSH7CAHXCHDZ2Z1R
Hop-Attempt: AT_06FP8DNXSKPDEVNXEZRQ5W8
This commit is contained in:
2026-07-14 22:24:30 -07:00
committed by Hop
parent e50316bbad
commit 988bd8ab38
7 changed files with 101 additions and 27 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const registerSchema = z.object({
handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),
displayName: z.string().optional(), displayName: z.string().trim().max(50).optional(),
turnstileToken: z.string().nullable().optional(), turnstileToken: z.string().nullable().optional(),
}); });
+10
View File
@@ -474,6 +474,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
}}>@</span> }}>@</span>
<input <input
type="text" type="text"
name="username"
autoComplete="username"
className="input" className="input"
value={handle} value={handle}
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
@@ -511,6 +513,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
</label> </label>
<input <input
type="password" type="password"
name="password"
autoComplete="new-password"
className="input" className="input"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -529,6 +533,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
</label> </label>
<input <input
type="text" type="text"
name="displayName"
autoComplete="nickname"
className="input" className="input"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@@ -541,6 +547,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
</label> </label>
<input <input
type="password" type="password"
name="confirmPassword"
autoComplete="new-password"
className="input" className="input"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
@@ -558,6 +566,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
</label> </label>
<input <input
type="email" type="email"
name="email"
autoComplete="email"
className="input" className="input"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
+56 -24
View File
@@ -31,6 +31,8 @@ interface UserSummary {
isBot?: boolean; isBot?: boolean;
} }
type ProfileMediaField = 'avatarUrl' | 'headerUrl';
// Strip HTML tags from a string // Strip HTML tags from a string
const stripHtml = (html: string | null | undefined): string | null => { const stripHtml = (html: string | null | undefined): string | null => {
if (!html) return null; if (!html) return null;
@@ -79,7 +81,7 @@ export default function ProfilePage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const handle = (params.handle as string)?.replace(/^@/, '') || ''; 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<User | null>(null); const [user, setUser] = useState<User | null>(null);
const userFullHandle = user ? useFormattedHandle(user.handle) : ''; const userFullHandle = user ? useFormattedHandle(user.handle) : '';
@@ -111,6 +113,7 @@ export default function ProfilePage() {
}); });
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [mediaSaveStatus, setMediaSaveStatus] = useState<Partial<Record<ProfileMediaField, 'saving' | 'saved'>>>({});
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
useEffect(() => { useEffect(() => {
@@ -251,7 +254,7 @@ export default function ProfilePage() {
}; };
useEffect(() => { useEffect(() => {
if (user && currentUser?.handle === user.handle) { if (user && currentUser?.handle === user.handle && !isEditing) {
setProfileForm({ setProfileForm({
displayName: user.displayName || '', displayName: user.displayName || '',
bio: user.bio || '', bio: user.bio || '',
@@ -260,7 +263,7 @@ export default function ProfilePage() {
website: user.website || '', website: user.website || '',
}); });
} }
}, [user, currentUser]); }, [user, currentUser, isEditing]);
useEffect(() => { useEffect(() => {
const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0]; const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0];
@@ -378,6 +381,24 @@ export default function ProfilePage() {
} }
}; };
const updateProfile = async (changes: Partial<typeof profileForm>): Promise<User> => {
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 () => { const handleSaveProfile = async () => {
if (!isOwnProfile) return; if (!isOwnProfile) return;
@@ -390,22 +411,10 @@ export default function ProfilePage() {
setSaveError(null); setSaveError(null);
try { try {
// Sign the profile update action const updatedUser = await updateProfile(profileForm);
const signedPayload = await signUserAction('update_profile', profileForm);
const res = await fetch('/api/auth/me', { setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser);
method: 'PATCH', updateUserProfile(updatedUser);
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);
setIsEditing(false); setIsEditing(false);
} catch (error) { } catch (error) {
console.error('Profile update failed', 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) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { return new Date(dateStr).toLocaleDateString('en-US', {
month: 'long', month: 'long',
@@ -785,26 +819,24 @@ export default function ProfilePage() {
label="Avatar" label="Avatar"
value={profileForm.avatarUrl} value={profileForm.avatarUrl}
onChange={(avatarUrl) => { onChange={(avatarUrl) => {
setSaveError(null); void handleProfileMediaChange('avatarUrl', avatarUrl);
setProfileForm({ ...profileForm, avatarUrl });
}} }}
previewWidth={48} previewWidth={48}
previewHeight={48} previewHeight={48}
previewBorderRadius="50%" 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)} onError={(message) => setSaveError(message || null)}
/> />
<UserStorageImageUpload <UserStorageImageUpload
label="Header" label="Header"
value={profileForm.headerUrl} value={profileForm.headerUrl}
onChange={(headerUrl) => { onChange={(headerUrl) => {
setSaveError(null); void handleProfileMediaChange('headerUrl', headerUrl);
setProfileForm({ ...profileForm, headerUrl });
}} }}
previewWidth={120} previewWidth={120}
previewHeight={40} previewHeight={40}
previewBorderRadius="4px" 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)} onError={(message) => setSaveError(message || null)}
/> />
{saveError && ( {saveError && (
+16
View File
@@ -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');
});
});
+7
View File
@@ -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;
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k
import { base58btc } from 'multiformats/bases/base58'; import { base58btc } from 'multiformats/bases/base58';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles'; import { upsertHandleEntries } from '@/lib/federation/handles';
import { registrationDisplayName } from '@/lib/auth/display-name';
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session'; const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_COOKIE_NAME = 'synapsis_sessions'; const SESSION_COOKIE_NAME = 'synapsis_sessions';
@@ -321,7 +322,7 @@ export async function registerUser(
handle: handle.toLowerCase(), handle: handle.toLowerCase(),
email: email.toLowerCase(), email: email.toLowerCase(),
passwordHash, passwordHash,
displayName: displayName || handle, displayName: registrationDisplayName(handle, email, displayName),
publicKey, publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
}).returning(); }).returning();
+9 -1
View File
@@ -8,7 +8,7 @@ export interface User {
handle: string; handle: string;
displayName: string; displayName: string;
email?: string; email?: string;
avatarUrl?: string; avatarUrl?: string | null;
did?: string; did?: string;
publicKey?: string; publicKey?: string;
privateKeyEncrypted?: string; privateKeyEncrypted?: string;
@@ -34,6 +34,7 @@ interface AuthContextType {
logout: (userId?: string) => Promise<void>; logout: (userId?: string) => Promise<void>;
switchAccount: (userId: string) => Promise<void>; switchAccount: (userId: string) => Promise<void>;
refreshAuth: () => Promise<void>; refreshAuth: () => Promise<void>;
updateUserProfile: (updates: Partial<User>) => void;
lockIdentity: () => Promise<void>; // New: manual lock lockIdentity: () => Promise<void>; // New: manual lock
signUserAction: (action: string, data: any) => Promise<any>; signUserAction: (action: string, data: any) => Promise<any>;
requiresUnlock: boolean; // True if user has encrypted key but not unlocked requiresUnlock: boolean; // True if user has encrypted key but not unlocked
@@ -57,6 +58,7 @@ const AuthContext = createContext<AuthContextType>({
logout: async () => { }, logout: async () => { },
switchAccount: async () => { }, switchAccount: async () => { },
refreshAuth: async () => { }, refreshAuth: async () => { },
updateUserProfile: () => { },
lockIdentity: async () => { }, lockIdentity: async () => { },
signUserAction: async () => Promise.reject('Not initialized'), signUserAction: async () => Promise.reject('Not initialized'),
requiresUnlock: false, requiresUnlock: false,
@@ -205,6 +207,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, [refreshAuth]); }, [refreshAuth]);
const updateUserProfile = useCallback((updates: Partial<User>) => {
setUser(current => current ? { ...current, ...updates } : current);
setAccounts(current => current.map(account => account.id === updates.id ? { ...account, ...updates } : account));
}, []);
// Load auth state on mount // Load auth state on mount
useEffect(() => { useEffect(() => {
refreshAuth(); refreshAuth();
@@ -231,6 +238,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout, logout,
switchAccount, switchAccount,
refreshAuth, refreshAuth,
updateUserProfile,
lockIdentity, lockIdentity,
signUserAction, signUserAction,
requiresUnlock, requiresUnlock,