feat: Implement identity lock screen for sensitive user actions and introduce avatar generation.
This commit is contained in:
@@ -10,6 +10,7 @@ import { requireAuth, verifyPassword, hashPassword } from '@/lib/auth';
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt the private key using the OLD password
|
* 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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
// Parse signed action
|
||||||
const body = await req.json();
|
const signedAction: SignedAction = await req.json();
|
||||||
const { currentPassword, newPassword } = body;
|
|
||||||
|
// 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) {
|
if (!currentPassword || !newPassword) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getSession, requireAuth } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
displayName: z.string().min(1).max(50).optional(),
|
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 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = await requireAuth();
|
// Parse signed action
|
||||||
const body = await request.json();
|
const signedAction: SignedAction = await request.json();
|
||||||
const data = updateProfileSchema.parse(body);
|
|
||||||
|
// 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: {
|
const updateData: {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -119,8 +131,13 @@ export async function PATCH(request: Request) {
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (error instanceof Error && error.message === 'Authentication required') {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
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);
|
console.error('Profile update error:', error);
|
||||||
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LAZY LOAD: If remote and not cached, try to fetch it now
|
// LAZY LOAD: If remote and (not cached OR missing avatar), try to fetch it now
|
||||||
if (!cachedUser && isRemote) {
|
if (isRemote && (!cachedUser || !cachedUser.avatarUrl)) {
|
||||||
try {
|
try {
|
||||||
const [rHandle, rDomain] = participant2Handle.split('@');
|
const [rHandle, rDomain] = participant2Handle.split('@');
|
||||||
const { fetchSwarmUserProfile } = await import('@/lib/swarm/interactions');
|
const { fetchSwarmUserProfile } = await import('@/lib/swarm/interactions');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
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';
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (senderDids.size > 0) {
|
if (senderDids.size > 0) {
|
||||||
const found = await db.query.users.findMany({
|
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);
|
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
|
// Also fetch local users by handle if needed
|
||||||
if (senderHandles.size > 0) {
|
if (senderHandles.size > 0) {
|
||||||
const found = await db.query.users.findMany({
|
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);
|
found.forEach(u => usersByHandle[u.handle] = u);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '60vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--foreground-tertiary)'
|
||||||
|
}}>
|
||||||
|
<Loader2 className="animate-spin" size={24} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
return (
|
||||||
|
<IdentityLockScreen
|
||||||
|
title="Settings Locked"
|
||||||
|
description="To view or change your settings, you must unlock your identity. Your private keys are required to sign any changes you make."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { MessageSquare, Check } from 'lucide-react';
|
import { MessageSquare, Check } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
export default function PrivacySettingsPage() {
|
export default function PrivacySettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -13,6 +14,8 @@ export default function PrivacySettingsPage() {
|
|||||||
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
|
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
|
||||||
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||||
|
|
||||||
|
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -27,15 +30,23 @@ export default function PrivacySettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
|
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
|
||||||
|
// If identity is locked, prompt to unlock and return
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDmPrivacy(newValue);
|
setDmPrivacy(newValue);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const signedPayload = await signUserAction('update_profile', { dmPrivacy: newValue });
|
||||||
|
|
||||||
const res = await fetch('/api/auth/me', {
|
const res = await fetch('/api/auth/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ dmPrivacy: newValue }),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -44,6 +55,11 @@ export default function PrivacySettingsPage() {
|
|||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
|
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) {
|
} catch (error) {
|
||||||
setStatus({ type: 'error', message: 'An error occurred' });
|
setStatus({ type: 'error', message: 'An error occurred' });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
|
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
export default function SecuritySettingsPage() {
|
export default function SecuritySettingsPage() {
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
@@ -12,6 +13,7 @@ export default function SecuritySettingsPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -34,18 +36,35 @@ export default function SecuritySettingsPage() {
|
|||||||
return;
|
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);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
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', {
|
const res = await fetch('/api/account/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
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');
|
throw new Error(data.error || 'Failed to change password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+187
-100
@@ -7,9 +7,10 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
|||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { User, Post } from '@/lib/types';
|
import { User, Post } from '@/lib/types';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
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 { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface BotOwner {
|
interface BotOwner {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,6 +79,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 { isIdentityUnlocked, setShowUnlockPrompt, signUserAction } = useAuth();
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
@@ -111,6 +113,74 @@ export default function ProfilePage() {
|
|||||||
const [isBlocked, setIsBlocked] = useState(false);
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const headerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, 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(() => {
|
useEffect(() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -303,6 +373,11 @@ export default function ProfilePage() {
|
|||||||
const handleFollow = async () => {
|
const handleFollow = async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true, () => handleFollow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const method = isFollowing ? 'DELETE' : 'POST';
|
const method = isFollowing ? 'DELETE' : 'POST';
|
||||||
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
||||||
|
|
||||||
@@ -318,6 +393,11 @@ export default function ProfilePage() {
|
|||||||
const handleBlock = async () => {
|
const handleBlock = async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true, () => handleBlock());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const method = isBlocked ? 'DELETE' : 'POST';
|
const method = isBlocked ? 'DELETE' : 'POST';
|
||||||
const res = await fetch(`/api/users/${handle}/block`, { method });
|
const res = await fetch(`/api/users/${handle}/block`, { method });
|
||||||
|
|
||||||
@@ -333,14 +413,24 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
if (!isOwnProfile) return;
|
if (!isOwnProfile) return;
|
||||||
|
|
||||||
|
// If identity is locked, prompt to unlock and return
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Sign the profile update action
|
||||||
|
const signedPayload = await signUserAction('update_profile', profileForm);
|
||||||
|
|
||||||
const res = await fetch('/api/auth/me', {
|
const res = await fetch('/api/auth/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(profileForm),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -353,7 +443,14 @@ export default function ProfilePage() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profile update failed', 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 {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -465,12 +562,47 @@ export default function ProfilePage() {
|
|||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div style={{
|
{/* Banner */}
|
||||||
height: '150px',
|
<div
|
||||||
background: user.headerUrl
|
style={{
|
||||||
? `url(${user.headerUrl}) center/cover`
|
height: '150px',
|
||||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
||||||
}} />
|
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||||
|
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: isEditing ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
} else {
|
||||||
|
headerInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<Camera size={32} />
|
||||||
|
<input
|
||||||
|
ref={headerInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleUpload(e, 'headerUrl')}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Avatar & Actions */}
|
{/* Avatar & Actions */}
|
||||||
<div style={{ padding: '0 16px' }}>
|
<div style={{ padding: '0 16px' }}>
|
||||||
@@ -479,18 +611,54 @@ export default function ProfilePage() {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
}}>
|
}}>
|
||||||
<div className="avatar avatar-lg" style={{
|
<div
|
||||||
width: '96px',
|
className="avatar avatar-lg"
|
||||||
height: '96px',
|
style={{
|
||||||
fontSize: '36px',
|
width: '96px',
|
||||||
border: '4px solid var(--background)',
|
height: '96px',
|
||||||
marginTop: '-48px',
|
fontSize: '36px',
|
||||||
}}>
|
border: '4px solid var(--background)',
|
||||||
{user.avatarUrl ? (
|
marginTop: '-48px',
|
||||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
position: 'relative',
|
||||||
|
cursor: isEditing ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
} else {
|
||||||
|
avatarInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
|
||||||
|
<img src={(isEditing ? profileForm.avatarUrl : user.avatarUrl) || ''} alt={user.displayName || user.handle} />
|
||||||
) : (
|
) : (
|
||||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<Camera size={24} />
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleUpload(e, 'avatarUrl')}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
@@ -704,88 +872,7 @@ export default function ProfilePage() {
|
|||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* File inputs removed, now click-to-upload on visual elements */}
|
||||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
|
||||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setProfileForm(prev => ({ ...prev, avatarUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSaveError('Upload failed');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isSaving}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{profileForm.avatarUrl && (
|
|
||||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={profileForm.avatarUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header</label>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
|
||||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
|
||||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const res = await fetch('/api/uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
setProfileForm(prev => ({ ...prev, headerUrl: data.url }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSaveError('Upload failed');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isSaving}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{profileForm.headerUrl && (
|
|
||||||
<div style={{ width: '80px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
||||||
<img src={profileForm.headerUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Post, Attachment } from '@/lib/types';
|
|||||||
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface MediaAttachment extends Attachment {
|
interface MediaAttachment extends Attachment {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
@@ -20,6 +21,7 @@ interface ComposeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
|
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
|
||||||
|
const { isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [isPosting, setIsPosting] = useState(false);
|
const [isPosting, setIsPosting] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||||
@@ -43,7 +45,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
setCanPostNsfw(true);
|
setCanPostNsfw(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
|
|
||||||
fetch('/api/node')
|
fetch('/api/node')
|
||||||
.then(res => res.ok ? res.json() : null)
|
.then(res => res.ok ? res.json() : null)
|
||||||
@@ -52,7 +54,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
setIsNsfwNode(true);
|
setIsNsfwNode(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Detect URLs in content
|
// Detect URLs in content
|
||||||
@@ -89,6 +91,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!content.trim() || isPosting || isUploading) return;
|
if (!content.trim() || isPosting || isUploading) return;
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true, () => handleSubmit());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPosting(true);
|
setIsPosting(true);
|
||||||
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
|
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
|
||||||
setContent('');
|
setContent('');
|
||||||
@@ -237,7 +245,16 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="compose-actions">
|
<div className="compose-actions">
|
||||||
<label className="compose-media-button" title="Add media">
|
<label
|
||||||
|
className="compose-media-button"
|
||||||
|
title="Add media"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isUploading ? '...' : <ImageIcon size={20} />}
|
{isUploading ? '...' : <ImageIcon size={20} />}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Lock, Shield } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
|
interface IdentityLockScreenProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdentityLockScreen({
|
||||||
|
title = 'Identity Required',
|
||||||
|
description = 'Accessing settings requires your identity to be unlocked. Your private keys are used to sign changes to prove they came from you.',
|
||||||
|
icon
|
||||||
|
}: IdentityLockScreenProps) {
|
||||||
|
const { setShowUnlockPrompt } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
}}>
|
||||||
|
{icon || <Lock size={48} style={{ color: 'var(--accent)' }} />}
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
maxWidth: '400px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUnlockPrompt(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
|
>
|
||||||
|
<Shield size={16} />
|
||||||
|
Unlock Identity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ interface PostCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: 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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [liked, setLiked] = useState(post.isLiked || false);
|
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) => {
|
const handleLike = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true, () => handleLike(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentLiked = liked;
|
const currentLiked = liked;
|
||||||
setLiked(!currentLiked);
|
setLiked(!currentLiked);
|
||||||
onLike?.(post.id, currentLiked); // Pass current state before toggle
|
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) => {
|
const handleRepost = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowUnlockPrompt(true, () => handleRepost(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentReposted = reposted;
|
const currentReposted = reposted;
|
||||||
setReposted(!currentReposted);
|
setReposted(!currentReposted);
|
||||||
onRepost?.(post.id, currentReposted); // Pass current state before toggle
|
onRepost?.(post.id, currentReposted); // Pass current state before toggle
|
||||||
|
|||||||
@@ -107,17 +107,15 @@ export function Sidebar() {
|
|||||||
{user && (
|
{user && (
|
||||||
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`} title="Notifications">
|
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`} title="Notifications">
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', position: 'relative' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<span className="nav-label">Notifications</span>
|
<span className="nav-label">Notifications</span>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute',
|
|
||||||
top: '-4px',
|
|
||||||
right: '-4px',
|
|
||||||
width: '8px',
|
width: '8px',
|
||||||
height: '8px',
|
height: '8px',
|
||||||
background: 'var(--error)',
|
background: 'var(--error)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0
|
||||||
}} className="notification-dot" />
|
}} className="notification-dot" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,17 +126,15 @@ export function Sidebar() {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', position: 'relative' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<span className="nav-label">Chat</span>
|
<span className="nav-label">Chat</span>
|
||||||
{unreadChatCount > 0 && (
|
{unreadChatCount > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute',
|
|
||||||
top: '-4px',
|
|
||||||
right: '-4px',
|
|
||||||
width: '8px',
|
width: '8px',
|
||||||
height: '8px',
|
height: '8px',
|
||||||
background: 'var(--error)',
|
background: 'var(--error)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0
|
||||||
}} className="notification-dot" />
|
}} className="notification-dot" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import { generateKeyPair } from '@/lib/crypto/keys';
|
|||||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
|
import { generateAndUploadAvatar } from '@/lib/auth/avatar';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'synapsis_session';
|
const SESSION_COOKIE_NAME = 'synapsis_session';
|
||||||
const SESSION_EXPIRY_DAYS = 30;
|
const SESSION_EXPIRY_DAYS = 30;
|
||||||
@@ -157,17 +158,23 @@ export async function registerUser(
|
|||||||
const did = generateDID();
|
const did = generateDID();
|
||||||
const passwordHash = await hashPassword(password);
|
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({
|
const [user] = await db.insert(users).values({
|
||||||
did,
|
did,
|
||||||
handle: handle.toLowerCase(),
|
handle: handle.toLowerCase(),
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
displayName: displayName || handle,
|
displayName: displayName || handle,
|
||||||
|
avatarUrl,
|
||||||
publicKey,
|
publicKey,
|
||||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
|
||||||
await upsertHandleEntries([{
|
await upsertHandleEntries([{
|
||||||
handle: user.handle,
|
handle: user.handle,
|
||||||
did: user.did,
|
did: user.did,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ interface AuthContextType {
|
|||||||
login: (user: User) => void;
|
login: (user: User) => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
showUnlockPrompt: boolean;
|
showUnlockPrompt: boolean;
|
||||||
setShowUnlockPrompt: (show: boolean) => void;
|
setShowUnlockPrompt: (show: boolean, onSuccess?: () => void) => void;
|
||||||
|
signUserAction: (action: string, data: any) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -41,6 +42,7 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
logout: async () => { },
|
logout: async () => { },
|
||||||
showUnlockPrompt: false,
|
showUnlockPrompt: false,
|
||||||
setShowUnlockPrompt: () => { },
|
setShowUnlockPrompt: () => { },
|
||||||
|
signUserAction: async () => Promise.reject('Not initialized'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -55,6 +57,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
initializeIdentity,
|
initializeIdentity,
|
||||||
unlockIdentity: unlockIdentityHook,
|
unlockIdentity: unlockIdentityHook,
|
||||||
clearIdentity,
|
clearIdentity,
|
||||||
|
signUserAction,
|
||||||
} = useUserIdentity();
|
} = useUserIdentity();
|
||||||
|
|
||||||
const checkAdmin = async () => {
|
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
|
* Unlock the user's identity with their password
|
||||||
@@ -87,6 +115,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
targetUser.publicKey
|
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
|
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)
|
// Clear the user's identity (private key from localStorage)
|
||||||
clearIdentity();
|
clearIdentity();
|
||||||
setShowUnlockPrompt(false);
|
setShowUnlockPrompt(false);
|
||||||
|
setOnUnlockCallback(null);
|
||||||
|
|
||||||
// Clear the user state
|
// Clear the user state
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -172,6 +210,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
showUnlockPrompt,
|
showUnlockPrompt,
|
||||||
setShowUnlockPrompt,
|
setShowUnlockPrompt,
|
||||||
|
signUserAction,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -23,21 +23,37 @@ export async function upsertRemoteUser(profile: RemoteProfile) {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update metadata if changed
|
// 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)
|
await db.update(users)
|
||||||
.set({
|
.set({
|
||||||
displayName: profile.displayName || existing.displayName,
|
displayName: profile.displayName || existing.displayName,
|
||||||
avatarUrl: profile.avatarUrl || existing.avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
isBot: profile.isBot ?? existing.isBot,
|
isBot: profile.isBot ?? existing.isBot,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(users.id, existing.id));
|
.where(eq(users.id, existing.id));
|
||||||
} else {
|
} 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
|
// Create new placeholder user
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
did: profile.did,
|
did: profile.did,
|
||||||
handle: profile.handle, // user@domain
|
handle: profile.handle, // user@domain
|
||||||
displayName: profile.displayName || profile.handle,
|
displayName: profile.displayName || profile.handle,
|
||||||
avatarUrl: profile.avatarUrl || null,
|
avatarUrl: avatarUrl,
|
||||||
isBot: profile.isBot || false,
|
isBot: profile.isBot || false,
|
||||||
publicKey: '', // We don't necessarily have their public key yet, but DMs often do
|
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
|
// Note: nodeId is null for remote placeholders unless we specifically link it
|
||||||
|
|||||||
Reference in New Issue
Block a user