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,