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_]+$/),
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user