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:
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -474,6 +474,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
||||
}}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
className="input"
|
||||
value={handle}
|
||||
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>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -529,6 +533,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="displayName"
|
||||
autoComplete="nickname"
|
||||
className="input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
@@ -541,6 +547,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
className="input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
@@ -558,6 +566,8 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
|
||||
+56
-24
@@ -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<User | null>(null);
|
||||
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
|
||||
@@ -111,6 +113,7 @@ export default function ProfilePage() {
|
||||
});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [mediaSaveStatus, setMediaSaveStatus] = useState<Partial<Record<ProfileMediaField, 'saving' | 'saved'>>>({});
|
||||
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<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 () => {
|
||||
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)}
|
||||
/>
|
||||
<UserStorageImageUpload
|
||||
label="Header"
|
||||
value={profileForm.headerUrl}
|
||||
onChange={(headerUrl) => {
|
||||
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 && (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void>;
|
||||
switchAccount: (userId: string) => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
updateUserProfile: (updates: Partial<User>) => void;
|
||||
lockIdentity: () => Promise<void>; // New: manual lock
|
||||
signUserAction: (action: string, data: any) => Promise<any>;
|
||||
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
|
||||
@@ -57,6 +58,7 @@ const AuthContext = createContext<AuthContextType>({
|
||||
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<User>) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user