From 10a54a0ea977eb6550f573258f5822915f4f4594 Mon Sep 17 00:00:00 2001 From: Christomatt Date: Fri, 30 Jan 2026 04:44:57 +0100 Subject: [PATCH] feat: Implement identity lock screen for sensitive user actions and introduce avatar generation. --- src/app/api/account/password/route.ts | 15 +- src/app/api/auth/me/route.ts | 29 +- src/app/api/swarm/chat/conversations/route.ts | 4 +- src/app/api/swarm/chat/messages/route.ts | 6 +- src/app/settings/layout.tsx | 34 +++ src/app/settings/privacy/page.tsx | 18 +- src/app/settings/security/page.tsx | 21 +- src/app/u/[handle]/page.tsx | 287 ++++++++++++------ src/components/Compose.tsx | 25 +- src/components/IdentityLockScreen.tsx | 53 ++++ src/components/PostCard.tsx | 14 +- src/components/Sidebar.tsx | 12 +- src/lib/auth/avatar.ts | 63 ++++ src/lib/auth/index.ts | 9 +- src/lib/contexts/AuthContext.tsx | 43 ++- src/lib/swarm/user-cache.ts | 20 +- 16 files changed, 519 insertions(+), 134 deletions(-) create mode 100644 src/app/settings/layout.tsx create mode 100644 src/components/IdentityLockScreen.tsx create mode 100644 src/lib/auth/avatar.ts 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 ? ( - {user.displayName +
{ + if (isEditing) { + if (!isIdentityUnlocked) { + setShowUnlockPrompt(true); + } else { + avatarInputRef.current?.click(); + } + } + }} + > + {(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? ( + {user.displayName ) : ( (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 && ( -
- Preview -
- )} -
-
-
- -
- - {profileForm.headerUrl && ( -
- Preview -
- )} -
-
+ {/* 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