diff --git a/src/app/api/account/password/route.ts b/src/app/api/account/password/route.ts
index 77c00d6..82fbf93 100644
--- a/src/app/api/account/password/route.ts
+++ b/src/app/api/account/password/route.ts
@@ -10,6 +10,7 @@ import { requireAuth, verifyPassword, hashPassword } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import * as crypto from 'crypto';
+import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
/**
* Decrypt the private key using the OLD password
@@ -70,9 +71,17 @@ function encryptPrivateKey(privateKey: string, password: string): { encrypted: s
export async function POST(req: NextRequest) {
try {
- const user = await requireAuth();
- const body = await req.json();
- const { currentPassword, newPassword } = body;
+ // Parse signed action
+ const signedAction: SignedAction = await req.json();
+
+ // Verify signature and get user
+ const user = await requireSignedAction(signedAction);
+
+ if (signedAction.action !== 'change_password') {
+ return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
+ }
+
+ const { currentPassword, newPassword } = signedAction.data;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index ec28a32..a56e9c6 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
-import { getSession, requireAuth } from '@/lib/auth';
+import { getSession } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
+import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(50).optional(),
@@ -52,9 +53,20 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
- const currentUser = await requireAuth();
- const body = await request.json();
- const data = updateProfileSchema.parse(body);
+ // Parse signed action
+ const signedAction: SignedAction = await request.json();
+
+ // Verify signature and get user
+ // This ensures the request was signed by the user's private key
+ const currentUser = await requireSignedAction(signedAction);
+
+ // Ensure the action type is correct for profile updates
+ if (signedAction.action !== 'update_profile') {
+ return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
+ }
+
+ // Parse inner data
+ const data = updateProfileSchema.parse(signedAction.data);
const updateData: {
displayName?: string;
@@ -119,8 +131,13 @@ export async function PATCH(request: Request) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
- if (error instanceof Error && error.message === 'Authentication required') {
- return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ if (error instanceof Error) {
+ if (error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ if (error.message === 'Invalid signature' || error.message === 'User not found') {
+ return NextResponse.json({ error: 'Invalid signature or identity' }, { status: 403 });
+ }
}
console.error('Profile update error:', error);
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts
index 7e535aa..3630b1a 100644
--- a/src/app/api/swarm/chat/conversations/route.ts
+++ b/src/app/api/swarm/chat/conversations/route.ts
@@ -71,8 +71,8 @@ export async function GET(request: NextRequest) {
}
}
- // LAZY LOAD: If remote and not cached, try to fetch it now
- if (!cachedUser && isRemote) {
+ // LAZY LOAD: If remote and (not cached OR missing avatar), try to fetch it now
+ if (isRemote && (!cachedUser || !cachedUser.avatarUrl)) {
try {
const [rHandle, rDomain] = participant2Handle.split('@');
const { fetchSwarmUserProfile } = await import('@/lib/swarm/interactions');
diff --git a/src/app/api/swarm/chat/messages/route.ts b/src/app/api/swarm/chat/messages/route.ts
index a042879..9618b1c 100644
--- a/src/app/api/swarm/chat/messages/route.ts
+++ b/src/app/api/swarm/chat/messages/route.ts
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
-import { eq, desc, and, lt, isNull, sql } from 'drizzle-orm';
+import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
@@ -73,7 +73,7 @@ export async function GET(request: NextRequest) {
if (senderDids.size > 0) {
const found = await db.query.users.findMany({
- where: sql`${users.did} IN ${Array.from(senderDids)}`
+ where: inArray(users.did, Array.from(senderDids))
});
found.forEach(u => usersByDid[u.did] = u);
}
@@ -81,7 +81,7 @@ export async function GET(request: NextRequest) {
// Also fetch local users by handle if needed
if (senderHandles.size > 0) {
const found = await db.query.users.findMany({
- where: sql`${users.handle} IN ${Array.from(senderHandles)}`
+ where: inArray(users.handle, Array.from(senderHandles))
});
found.forEach(u => usersByHandle[u.handle] = u);
}
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
new file mode 100644
index 0000000..59bafbc
--- /dev/null
+++ b/src/app/settings/layout.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { useAuth } from '@/lib/contexts/AuthContext';
+import { IdentityLockScreen } from '@/components/IdentityLockScreen';
+import { Loader2 } from 'lucide-react';
+
+export default function SettingsLayout({ children }: { children: React.ReactNode }) {
+ const { isIdentityUnlocked, loading } = useAuth();
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isIdentityUnlocked) {
+ return (
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/src/app/settings/privacy/page.tsx b/src/app/settings/privacy/page.tsx
index 4997971..7b767ed 100644
--- a/src/app/settings/privacy/page.tsx
+++ b/src/app/settings/privacy/page.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeftIcon } from '@/components/Icons';
import { MessageSquare, Check } from 'lucide-react';
+import { useAuth } from '@/lib/contexts/AuthContext';
export default function PrivacySettingsPage() {
const router = useRouter();
@@ -13,6 +14,8 @@ export default function PrivacySettingsPage() {
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
+ const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
+
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
@@ -27,15 +30,23 @@ export default function PrivacySettingsPage() {
}, []);
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
+ // If identity is locked, prompt to unlock and return
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ return;
+ }
+
setDmPrivacy(newValue);
setSaving(true);
setStatus(null);
try {
+ const signedPayload = await signUserAction('update_profile', { dmPrivacy: newValue });
+
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dmPrivacy: newValue }),
+ body: JSON.stringify(signedPayload),
});
if (res.ok) {
@@ -44,6 +55,11 @@ export default function PrivacySettingsPage() {
} else {
const data = await res.json();
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
+
+ // If error due to identity lock
+ if (data.error === 'Invalid signature or identity') {
+ setShowUnlockPrompt(true);
+ }
}
} catch (error) {
setStatus({ type: 'error', message: 'An error occurred' });
diff --git a/src/app/settings/security/page.tsx b/src/app/settings/security/page.tsx
index b18e554..1b27eec 100644
--- a/src/app/settings/security/page.tsx
+++ b/src/app/settings/security/page.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
+import { useAuth } from '@/lib/contexts/AuthContext';
export default function SecuritySettingsPage() {
const [currentPassword, setCurrentPassword] = useState('');
@@ -12,6 +13,7 @@ export default function SecuritySettingsPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
+ const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -34,18 +36,35 @@ export default function SecuritySettingsPage() {
return;
}
+ // If identity is locked, prompt to unlock and return
+ // Note: It seems redundant since they entered the password,
+ // but this ensures the KEY is loaded in memory.
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ return;
+ }
+
setIsSubmitting(true);
try {
+ // Sign the password change action
+ // This proves we have the key unlocked (which required knowing the password)
+ const signedPayload = await signUserAction('change_password', { currentPassword, newPassword });
+
const res = await fetch('/api/account/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ currentPassword, newPassword }),
+ body: JSON.stringify(signedPayload),
});
const data = await res.json();
if (!res.ok) {
+ // If error due to identity lock
+ if (data.error === 'Invalid signature or identity' || data.error === 'User not found') {
+ setShowUnlockPrompt(true);
+ throw new Error('Identity verification failed. Please unlock your identity.');
+ }
throw new Error(data.error || 'Failed to change password');
}
diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx
index 2a76dd6..f7bdeae 100644
--- a/src/app/u/[handle]/page.tsx
+++ b/src/app/u/[handle]/page.tsx
@@ -7,9 +7,10 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
import { PostCard } from '@/components/PostCard';
import { User, Post } from '@/lib/types';
import AutoTextarea from '@/components/AutoTextarea';
-import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
+import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react';
+import { useAuth } from '@/lib/contexts/AuthContext';
interface BotOwner {
id: string;
@@ -78,6 +79,7 @@ export default function ProfilePage() {
const params = useParams();
const router = useRouter();
const handle = (params.handle as string)?.replace(/^@/, '') || '';
+ const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
@@ -111,6 +113,74 @@ export default function ProfilePage() {
const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false);
+ const avatarInputRef = useRef(null);
+ const headerInputRef = useRef(null);
+
+ const handleUpload = async (e: React.ChangeEvent, field: 'avatarUrl' | 'headerUrl') => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Reset inputs so change event fires again for same file
+ e.target.value = '';
+
+ setIsSaving(true);
+ try {
+ // 1. Upload the file
+ const formData = new FormData();
+ formData.append('file', file);
+ const uploadRes = await fetch('/api/uploads', {
+ method: 'POST',
+ body: formData,
+ });
+ const uploadData = await uploadRes.json();
+
+ if (!uploadData.url) throw new Error('Upload failed');
+ const imageUrl = uploadData.url;
+
+ // 2. Update local form state immediately for UI feedback
+ setProfileForm(prev => ({ ...prev, [field]: imageUrl }));
+
+ // 3. Auto-save the profile change
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ throw new Error('Please unlock your identity to save the changes.');
+ }
+
+ // Create partial update payload
+ const updatePayload: any = { [field]: imageUrl };
+
+ // Sign the action
+ const signedPayload = await signUserAction('update_profile', updatePayload);
+
+ const saveRes = await fetch('/api/auth/me', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(signedPayload),
+ });
+
+ const saveData = await saveRes.json();
+
+ if (!saveRes.ok) {
+ // If error due to identity lock
+ if (saveData.error === 'Invalid signature or identity') {
+ setShowUnlockPrompt(true);
+ throw new Error('Identity verification failed. Please try again after unlocking.');
+ }
+ throw new Error(saveData.error || 'Failed to update profile');
+ }
+
+ // Update user state with the returned updated user
+ setUser(saveData.user);
+ // We do NOT exit edit mode automatically, allowing them to edit other fields
+
+ } catch (err) {
+ console.error(err);
+ setSaveError(err instanceof Error ? err.message : 'Upload failed');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
useEffect(() => {
setIsEditing(false);
setSaveError(null);
@@ -303,6 +373,11 @@ export default function ProfilePage() {
const handleFollow = async () => {
if (!currentUser) return;
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true, () => handleFollow());
+ return;
+ }
+
const method = isFollowing ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${handle}/follow`, { method });
@@ -318,6 +393,11 @@ export default function ProfilePage() {
const handleBlock = async () => {
if (!currentUser) return;
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true, () => handleBlock());
+ return;
+ }
+
const method = isBlocked ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${handle}/block`, { method });
@@ -333,14 +413,24 @@ export default function ProfilePage() {
const handleSaveProfile = async () => {
if (!isOwnProfile) return;
+
+ // If identity is locked, prompt to unlock and return
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ return;
+ }
+
setIsSaving(true);
setSaveError(null);
try {
+ // Sign the profile update action
+ const signedPayload = await signUserAction('update_profile', profileForm);
+
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(profileForm),
+ body: JSON.stringify(signedPayload),
});
const data = await res.json();
@@ -353,7 +443,14 @@ export default function ProfilePage() {
setIsEditing(false);
} catch (error) {
console.error('Profile update failed', error);
- setSaveError('Unable to update profile. Please try again.');
+ setSaveError(error instanceof Error && error.message.includes('Identity locked')
+ ? 'Please unlock your identity to save changes.'
+ : 'Unable to update profile. Please try again.');
+
+ // If the error was due to lock state (race condition), prompt unlock
+ if (error instanceof Error && error.message.includes('Identity locked')) {
+ setShowUnlockPrompt(true);
+ }
} finally {
setIsSaving(false);
}
@@ -465,12 +562,47 @@ export default function ProfilePage() {
{/* Profile Header */}
{/* Banner */}
-
+ {/* Banner */}
+
{
+ if (isEditing) {
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ } else {
+ headerInputRef.current?.click();
+ }
+ }
+ }}
+ >
+ {isEditing && (
+
+
+ handleUpload(e, 'headerUrl')}
+ style={{ display: 'none' }}
+ />
+
+ )}
+
{/* Avatar & Actions */}
@@ -479,18 +611,54 @@ export default function ProfilePage() {
justifyContent: 'space-between',
alignItems: 'flex-start',
}}>
-
- {user.avatarUrl ? (
-

+
{
+ if (isEditing) {
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true);
+ } else {
+ avatarInputRef.current?.click();
+ }
+ }
+ }}
+ >
+ {(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
+

) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
+
+ {isEditing && (
+
+
+ handleUpload(e, 'avatarUrl')}
+ style={{ display: 'none' }}
+ />
+
+ )}
@@ -704,88 +872,7 @@ export default function ProfilePage() {
maxLength={100}
/>
-
-
-
-
- {profileForm.avatarUrl && (
-
-

-
- )}
-
-
-
-
-
-
- {profileForm.headerUrl && (
-
-

-
- )}
-
-
+ {/* File inputs removed, now click-to-upload on visual elements */}
{saveError && (
{saveError}
)}
diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx
index 5e0e35b..a6cde40 100644
--- a/src/components/Compose.tsx
+++ b/src/components/Compose.tsx
@@ -6,6 +6,7 @@ import { Post, Attachment } from '@/lib/types';
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
import { VideoEmbed } from '@/components/VideoEmbed';
import { formatFullHandle } from '@/lib/utils/handle';
+import { useAuth } from '@/lib/contexts/AuthContext';
interface MediaAttachment extends Attachment {
mimeType?: string;
@@ -20,6 +21,7 @@ interface ComposeProps {
}
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
+ const { isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState
([]);
@@ -43,8 +45,8 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
setCanPostNsfw(true);
}
})
- .catch(() => {});
-
+ .catch(() => { });
+
fetch('/api/node')
.then(res => res.ok ? res.json() : null)
.then(data => {
@@ -52,7 +54,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
setIsNsfwNode(true);
}
})
- .catch(() => {});
+ .catch(() => { });
}, []);
// Detect URLs in content
@@ -89,6 +91,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const handleSubmit = async () => {
if (!content.trim() || isPosting || isUploading) return;
+
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true, () => handleSubmit());
+ return;
+ }
+
setIsPosting(true);
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
setContent('');
@@ -237,7 +245,16 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
)}
+ );
+}
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index a1ff6f9..3757b96 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -44,7 +44,7 @@ interface PostCardProps {
}
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) {
- const { user: currentUser, did, handle: currentUserHandle } = useAuth();
+ const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
const { showToast } = useToast();
const router = useRouter();
const [liked, setLiked] = useState(post.isLiked || false);
@@ -89,6 +89,12 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const handleLike = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
+
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true, () => handleLike(e));
+ return;
+ }
+
const currentLiked = liked;
setLiked(!currentLiked);
onLike?.(post.id, currentLiked); // Pass current state before toggle
@@ -97,6 +103,12 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const handleRepost = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
+
+ if (!isIdentityUnlocked) {
+ setShowUnlockPrompt(true, () => handleRepost(e));
+ return;
+ }
+
const currentReposted = reposted;
setReposted(!currentReposted);
onRepost?.(post.id, currentReposted); // Pass current state before toggle
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 1959fa3..cc2ac94 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -107,17 +107,15 @@ export function Sidebar() {
{user && (
-
+
Notifications
{unreadCount > 0 && (
)}
@@ -128,17 +126,15 @@ export function Sidebar() {
-
+
Chat
{unreadChatCount > 0 && (
)}
diff --git a/src/lib/auth/avatar.ts b/src/lib/auth/avatar.ts
new file mode 100644
index 0000000..a835d8a
--- /dev/null
+++ b/src/lib/auth/avatar.ts
@@ -0,0 +1,63 @@
+import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
+import { v4 as uuidv4 } from 'uuid';
+
+export async function generateAndUploadAvatar(handle: string): Promise {
+ try {
+ // 1. Fetch the avatar from DiceBear
+ const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
+ const response = await fetch(dicebearUrl);
+
+ if (!response.ok) {
+ console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
+ return null;
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // 2. Upload to S3
+ const s3 = new S3Client({
+ region: process.env.STORAGE_REGION || 'us-east-1',
+ endpoint: process.env.STORAGE_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
+ secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
+ },
+ forcePathStyle: true,
+ });
+
+ const bucket = process.env.STORAGE_BUCKET || 'synapsis';
+ // Sanitize handle for filename just in case
+ const safeHandle = handle.replace(/[^a-zA-Z0-9]/g, '');
+ const filename = `${uuidv4()}-${safeHandle}-avatar.svg`;
+
+ await s3.send(new PutObjectCommand({
+ Bucket: bucket,
+ Key: filename,
+ Body: buffer,
+ ContentType: 'image/svg+xml',
+ ACL: 'public-read',
+ }));
+
+ // 3. Construct Public URL
+ let url = '';
+ if (process.env.STORAGE_PUBLIC_BASE_URL) {
+ url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
+ } else if (process.env.STORAGE_ENDPOINT) {
+ // Basic fallback construction if base url is not set but endpoint is
+ // This assumes path style access is okay if no custom domain
+ const endpoint = process.env.STORAGE_ENDPOINT.replace(/\/+$/, '');
+ url = `${endpoint}/${bucket}/${filename}`;
+ } else {
+ console.warn('Storage public URL not configured properly');
+ return null;
+ }
+
+ return url;
+
+ } catch (error) {
+ console.error('Error generating/uploading avatar:', error);
+ return null;
+ }
+}
+
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
index 18a31c5..a89ebd2 100644
--- a/src/lib/auth/index.ts
+++ b/src/lib/auth/index.ts
@@ -10,6 +10,7 @@ import { generateKeyPair } from '@/lib/crypto/keys';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
+import { generateAndUploadAvatar } from '@/lib/auth/avatar';
const SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_EXPIRY_DAYS = 30;
@@ -157,17 +158,23 @@ export async function registerUser(
const did = generateDID();
const passwordHash = await hashPassword(password);
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+
+ // Generate avatar using full handle (@user@domain) for global uniqueness
+ const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
+ const avatarUrl = await generateAndUploadAvatar(fullHandle);
+
const [user] = await db.insert(users).values({
did,
handle: handle.toLowerCase(),
email: email.toLowerCase(),
passwordHash,
displayName: displayName || handle,
+ avatarUrl,
publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
}).returning();
- const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
await upsertHandleEntries([{
handle: user.handle,
did: user.did,
diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx
index 60f4ac5..9f2285d 100644
--- a/src/lib/contexts/AuthContext.tsx
+++ b/src/lib/contexts/AuthContext.tsx
@@ -25,7 +25,8 @@ interface AuthContextType {
login: (user: User) => void;
logout: () => Promise;
showUnlockPrompt: boolean;
- setShowUnlockPrompt: (show: boolean) => void;
+ setShowUnlockPrompt: (show: boolean, onSuccess?: () => void) => void;
+ signUserAction: (action: string, data: any) => Promise;
}
const AuthContext = createContext({
@@ -41,6 +42,7 @@ const AuthContext = createContext({
logout: async () => { },
showUnlockPrompt: false,
setShowUnlockPrompt: () => { },
+ signUserAction: async () => Promise.reject('Not initialized'),
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -55,6 +57,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
initializeIdentity,
unlockIdentity: unlockIdentityHook,
clearIdentity,
+ signUserAction,
} = useUserIdentity();
const checkAdmin = async () => {
@@ -67,7 +70,32 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
- const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
+ const [showUnlockPrompt, _setShowUnlockPrompt] = useState(false);
+ const [onUnlockCallback, setOnUnlockCallback] = useState<(() => void) | null>(null);
+
+ const setShowUnlockPrompt = (show: boolean, onSuccess?: () => void) => {
+ _setShowUnlockPrompt(show);
+ if (show && onSuccess) {
+ setOnUnlockCallback(() => onSuccess);
+ } else if (!show) {
+ // If hiding without success (cancel), clear callback ???
+ // Actually unlockIdentity handles success case.
+ // If explicit hide (cancel), we should probably clear it.
+ // But unlockIdentity calls setShowUnlockPrompt(false) on success too.
+ // So we handle callback execution in unlockIdentity,
+ // and clearing in unlockIdentity OR here if it wasn't executed?
+
+ // Let's rely on unlockIdentity to execute and clear.
+ // If just closing dialog (cancel), we clear it.
+ // But we don't know if this call is from cancel or success?
+ // unlockIdentity calls this.
+ }
+ };
+
+ // Clear callback on close if it wasn't executed?
+ // It's safer to clear it when closing prompt to avoid stale callbacks.
+ // But unlockIdentity calls setShowUnlockPrompt(false) AFTER executing.
+ // So:
/**
* Unlock the user's identity with their password
@@ -87,6 +115,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
targetUser.publicKey
);
+ // Execute queued callback if exists
+ if (onUnlockCallback) {
+ try {
+ onUnlockCallback();
+ } catch (e) {
+ console.error('Error executing unlock callback:', e);
+ }
+ setOnUnlockCallback(null);
+ }
setShowUnlockPrompt(false); // Close prompt on success
};
@@ -111,6 +148,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Clear the user's identity (private key from localStorage)
clearIdentity();
setShowUnlockPrompt(false);
+ setOnUnlockCallback(null);
// Clear the user state
setUser(null);
@@ -172,6 +210,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout,
showUnlockPrompt,
setShowUnlockPrompt,
+ signUserAction,
}}>
{children}
diff --git a/src/lib/swarm/user-cache.ts b/src/lib/swarm/user-cache.ts
index 7c29cad..9277967 100644
--- a/src/lib/swarm/user-cache.ts
+++ b/src/lib/swarm/user-cache.ts
@@ -23,21 +23,37 @@ export async function upsertRemoteUser(profile: RemoteProfile) {
if (existing) {
// Update metadata if changed
+ let avatarUrl = profile.avatarUrl || existing.avatarUrl;
+
+ // If still no avatar, generate one and save it "permanently"
+ if (!avatarUrl) {
+ const { generateAndUploadAvatar } = await import('@/lib/auth/avatar');
+ avatarUrl = await generateAndUploadAvatar(profile.handle);
+ }
+
await db.update(users)
.set({
displayName: profile.displayName || existing.displayName,
- avatarUrl: profile.avatarUrl || existing.avatarUrl,
+ avatarUrl: avatarUrl,
isBot: profile.isBot ?? existing.isBot,
updatedAt: new Date(),
})
.where(eq(users.id, existing.id));
} else {
+ let avatarUrl = profile.avatarUrl;
+
+ // If no avatar provided, generate one
+ if (!avatarUrl) {
+ const { generateAndUploadAvatar } = await import('@/lib/auth/avatar');
+ avatarUrl = await generateAndUploadAvatar(profile.handle);
+ }
+
// Create new placeholder user
await db.insert(users).values({
did: profile.did,
handle: profile.handle, // user@domain
displayName: profile.displayName || profile.handle,
- avatarUrl: profile.avatarUrl || null,
+ avatarUrl: avatarUrl,
isBot: profile.isBot || false,
publicKey: '', // We don't necessarily have their public key yet, but DMs often do
// Note: nodeId is null for remote placeholders unless we specifically link it