diff --git a/drizzle/0012_add_logo_favicon_data.sql b/drizzle/0012_add_logo_favicon_data.sql new file mode 100644 index 0000000..c87cc2c --- /dev/null +++ b/drizzle/0012_add_logo_favicon_data.sql @@ -0,0 +1,3 @@ +-- Migration: Add logo_data and favicon_data columns for base64 storage +ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "logo_data" text; +ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "favicon_data" text; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fef25fa..8710b9c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -138,7 +138,8 @@ export default function AdminPage() { try { const formData = new FormData(); formData.append('file', file); - const res = await fetch('/api/media/upload', { + formData.append('type', 'logo'); + const res = await fetch('/api/admin/node/upload', { method: 'POST', body: formData, }); @@ -150,13 +151,13 @@ export default function AdminPage() { const nextSettings = { ...nodeSettings, - logoUrl: data.media?.url || data.url, + logoUrl: data.url, }; setNodeSettings(nextSettings); await handleSaveSettings(nextSettings); } catch (error) { console.error('Logo upload failed', error); - setLogoUploadError('Upload failed. Please try again.'); + setLogoUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingLogo(false); } @@ -173,7 +174,8 @@ export default function AdminPage() { try { const formData = new FormData(); formData.append('file', file); - const res = await fetch('/api/media/upload', { + formData.append('type', 'favicon'); + const res = await fetch('/api/admin/node/upload', { method: 'POST', body: formData, }); @@ -185,13 +187,13 @@ export default function AdminPage() { const nextSettings = { ...nodeSettings, - faviconUrl: data.media?.url || data.url, + faviconUrl: data.url, }; setNodeSettings(nextSettings); await handleSaveSettings(nextSettings); } catch (error) { console.error('Favicon upload failed', error); - setFaviconUploadError('Upload failed. Please try again.'); + setFaviconUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingFavicon(false); } diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts new file mode 100644 index 0000000..e78e774 --- /dev/null +++ b/src/app/api/account/delete/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from 'next/server'; +import { db, users, posts, sessions, likes, follows, notifications, chatMessages, chatConversations } from '@/db'; +import { eq, or, and } from 'drizzle-orm'; +import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; +import { verifyPassword } from '@/lib/auth'; +import { cookies } from 'next/headers'; + +export async function POST(request: Request) { + try { + const signedAction: SignedAction = await request.json(); + + // Verify signature and get user + const user = await requireSignedAction(signedAction); + + if (signedAction.action !== 'delete_account') { + return NextResponse.json( + { error: 'Invalid action type' }, + { status: 400 } + ); + } + + const { password } = signedAction.data; + + // Verify password + if (!user.passwordHash) { + return NextResponse.json( + { error: 'Account has no password set' }, + { status: 400 } + ); + } + + const isPasswordValid = await verifyPassword(password, user.passwordHash); + + if (!isPasswordValid) { + return NextResponse.json( + { error: 'Password is incorrect' }, + { status: 403 } + ); + } + + const userId = user.id; + const userDid = user.did; + + // Delete all user data in proper order to respect foreign keys + + // 1. Delete chat messages sent by this user + await db.delete(chatMessages) + .where(eq(chatMessages.senderDid, userDid)); + + // 2. Find and delete conversations where user is a participant + // First get conversation IDs where user is participant1 (local user) + // For participant2, we need to check by handle since it's stored as text (can be remote) + const conversations = await db.query.chatConversations.findMany({ + where: or( + eq(chatConversations.participant1Id, userId), + eq(chatConversations.participant2Handle, user.handle) + ), + }); + + const conversationIds = conversations.map(c => c.id); + + // Delete messages in those conversations + if (conversationIds.length > 0) { + for (const convId of conversationIds) { + await db.delete(chatMessages) + .where(eq(chatMessages.conversationId, convId)); + } + } + + // 3. Delete the conversations themselves + if (conversationIds.length > 0) { + for (const convId of conversationIds) { + await db.delete(chatConversations) + .where(eq(chatConversations.id, convId)); + } + } + + // 4. Delete notifications + await db.delete(notifications) + .where(or( + eq(notifications.userId, userId), + eq(notifications.actorId, userId) + )); + + // 5. Delete likes + await db.delete(likes) + .where(eq(likes.userId, userId)); + + // 6. Delete follows (both directions) + await db.delete(follows) + .where(or( + eq(follows.followerId, userId), + eq(follows.followingId, userId) + )); + + // 7. Delete posts (this will cascade delete reposts and post likes via triggers if set up) + await db.delete(posts) + .where(eq(posts.userId, userId)); + + // 8. Delete sessions + await db.delete(sessions) + .where(eq(sessions.userId, userId)); + + // 9. Finally, delete the user + await db.delete(users) + .where(eq(users.id, userId)); + + // Clear session cookie + const cookieStore = await cookies(); + cookieStore.delete('synapsis_session'); + + return NextResponse.json({ + success: true, + message: 'Account deleted successfully', + }); + + } catch (error) { + console.error('Account deletion error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete account' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/account/email/route.ts b/src/app/api/account/email/route.ts new file mode 100644 index 0000000..0f1e7c2 --- /dev/null +++ b/src/app/api/account/email/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { db, users } from '@/db'; +import { eq } from 'drizzle-orm'; +import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; +import { verifyPassword } from '@/lib/auth'; + +export async function POST(request: Request) { + try { + const signedAction: SignedAction = await request.json(); + + // Verify signature and get user + const user = await requireSignedAction(signedAction); + + if (signedAction.action !== 'change_email') { + return NextResponse.json( + { error: 'Invalid action type' }, + { status: 400 } + ); + } + + const { newEmail, currentPassword } = signedAction.data; + + // Verify current password + if (!user.passwordHash) { + return NextResponse.json( + { error: 'Account has no password set' }, + { status: 400 } + ); + } + + const isPasswordValid = await verifyPassword(currentPassword, user.passwordHash); + + if (!isPasswordValid) { + return NextResponse.json( + { error: 'Current password is incorrect' }, + { status: 403 } + ); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(newEmail)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ); + } + + // Check if email is already taken by another user + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, newEmail.toLowerCase()), + }); + + if (existingUser && existingUser.id !== user.id) { + return NextResponse.json( + { error: 'Email is already registered to another account' }, + { status: 400 } + ); + } + + // Update email + await db.update(users) + .set({ email: newEmail.toLowerCase() }) + .where(eq(users.id, user.id)); + + return NextResponse.json({ + success: true, + message: 'Email updated successfully', + }); + + } catch (error) { + console.error('Email change error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to change email' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/node/route.ts b/src/app/api/admin/node/route.ts index 2bb50d3..aa9b11c 100644 --- a/src/app/api/admin/node/route.ts +++ b/src/app/api/admin/node/route.ts @@ -33,29 +33,41 @@ export async function PATCH(req: NextRequest) { bannerUrl: data.bannerUrl, logoUrl: data.logoUrl, faviconUrl: data.faviconUrl, + logoData: data.logoData, + faviconData: data.faviconData, accentColor: data.accentColor, isNsfw: data.isNsfw ?? false, turnstileSiteKey: data.turnstileSiteKey, turnstileSecretKey: data.turnstileSecretKey, }).returning(); } else { + const updateData: Record = { + name: data.name, + description: data.description, + longDescription: data.longDescription, + rules: data.rules, + bannerUrl: data.bannerUrl, + logoUrl: data.logoUrl, + faviconUrl: data.faviconUrl, + accentColor: data.accentColor, + isNsfw: data.isNsfw ?? node.isNsfw, + turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey, + turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey, + // Fix domain drift: ensure the DB matches the current environment + domain: domain, + updatedAt: new Date(), + }; + + // Only update logoData/faviconData if explicitly provided + if (data.logoData !== undefined) { + updateData.logoData = data.logoData; + } + if (data.faviconData !== undefined) { + updateData.faviconData = data.faviconData; + } + [node] = await db.update(nodes) - .set({ - name: data.name, - description: data.description, - longDescription: data.longDescription, - rules: data.rules, - bannerUrl: data.bannerUrl, - logoUrl: data.logoUrl, - faviconUrl: data.faviconUrl, - accentColor: data.accentColor, - isNsfw: data.isNsfw ?? node.isNsfw, - turnstileSiteKey: data.turnstileSiteKey !== undefined ? data.turnstileSiteKey : node.turnstileSiteKey, - turnstileSecretKey: data.turnstileSecretKey !== undefined ? data.turnstileSecretKey : node.turnstileSecretKey, - // Fix domain drift: ensure the DB matches the current environment - domain: domain, - updatedAt: new Date(), - }) + .set(updateData) .where(eq(nodes.id, node.id)) .returning(); } diff --git a/src/app/api/admin/node/upload/route.ts b/src/app/api/admin/node/upload/route.ts new file mode 100644 index 0000000..3378f2e --- /dev/null +++ b/src/app/api/admin/node/upload/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, nodes } from '@/db'; +import { eq } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth/admin'; + +// Logo constraints +const MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB +const ALLOWED_LOGO_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; + +// Favicon constraints +const MAX_FAVICON_SIZE = 500 * 1024; // 500KB +const ALLOWED_FAVICON_TYPES = [ + 'image/x-icon', + 'image/vnd.microsoft.icon', + 'image/png', + 'image/svg+xml', +]; + +// Map file extensions to MIME types for validation +const MIME_TYPE_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', +}; + +export async function POST(req: NextRequest) { + try { + await requireAdmin(); + + const formData = await req.formData(); + const file = formData.get('file') as File | null; + const type = formData.get('type') as 'logo' | 'favicon' | null; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (!type || (type !== 'logo' && type !== 'favicon')) { + return NextResponse.json({ error: 'Invalid type. Must be "logo" or "favicon"' }, { status: 400 }); + } + + // Determine constraints based on type + const isLogo = type === 'logo'; + const maxSize = isLogo ? MAX_LOGO_SIZE : MAX_FAVICON_SIZE; + const allowedTypes = isLogo ? ALLOWED_LOGO_TYPES : ALLOWED_FAVICON_TYPES; + const typeName = isLogo ? 'Logo' : 'Favicon'; + + // Validate file size + if (file.size > maxSize) { + return NextResponse.json( + { error: `${typeName} too large. Maximum size: ${isLogo ? '2MB' : '500KB'}` }, + { status: 400 } + ); + } + + // Validate MIME type + let mimeType = file.type; + + // Handle cases where browser might not set correct MIME type + if (!mimeType || mimeType === 'application/octet-stream') { + const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); + mimeType = MIME_TYPE_MAP[ext] || ''; + } + + // Special handling for .ico files + if (file.name.toLowerCase().endsWith('.ico')) { + mimeType = 'image/x-icon'; + } + + if (!allowedTypes.includes(mimeType)) { + const allowedList = isLogo + ? 'PNG, JPG, GIF, WebP, SVG' + : 'ICO, PNG, SVG'; + return NextResponse.json( + { error: `Invalid file type for ${typeName.toLowerCase()}. Allowed: ${allowedList}` }, + { status: 400 } + ); + } + + // Convert file to base64 + const buffer = Buffer.from(await file.arrayBuffer()); + const base64Data = buffer.toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64Data}`; + + // Get current node + const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + let node = await db.query.nodes.findFirst({ + where: eq(nodes.domain, domain), + }); + + // Fallback: If not found, check if there is exactly ONE node in the system + if (!node) { + const allNodes = await db.query.nodes.findMany({ limit: 2 }); + if (allNodes.length === 1) { + node = allNodes[0]; + } + } + + if (!node) { + return NextResponse.json({ error: 'Node not found' }, { status: 404 }); + } + + // Update the appropriate field + const updateData = isLogo + ? { logoData: dataUrl, logoUrl: `/api/node/logo`, updatedAt: new Date() } + : { faviconData: dataUrl, faviconUrl: `/api/node/favicon`, updatedAt: new Date() }; + + await db.update(nodes) + .set(updateData) + .where(eq(nodes.id, node.id)); + + return NextResponse.json({ + success: true, + url: isLogo ? '/api/node/logo' : '/api/node/favicon', + type, + size: file.size, + }); + + } catch (error) { + if (error instanceof Error && error.message === 'Admin authentication required') { + return NextResponse.json({ error: 'Admin authentication required' }, { status: 401 }); + } + console.error('Node upload error:', error); + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } +} diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts index 28e0f9f..20de418 100644 --- a/src/app/api/bots/route.ts +++ b/src/app/api/bots/route.ts @@ -62,7 +62,7 @@ export async function POST(request: Request) { const data = createBotSchema.parse(body); // Generate bot avatar using owner's S3 storage if password provided and no avatar URL - let botAvatarUrl = data.avatarUrl; + let botAvatarUrl: string | null | undefined = data.avatarUrl; if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) { try { const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; @@ -77,7 +77,7 @@ export async function POST(request: Request) { botAvatarUrl = await generateAndUploadAvatarToUserStorage( botHandle, - (user.storageEndpoint ?? undefined) as string | null | undefined, + user.storageEndpoint || undefined, user.storageRegion || 'auto', user.storageBucket, accessKeyId, @@ -93,7 +93,7 @@ export async function POST(request: Request) { name: data.name, handle: data.handle, bio: data.bio, - avatarUrl: botAvatarUrl, + avatarUrl: botAvatarUrl ?? undefined, headerUrl: data.headerUrl, personality: data.personality, llmProvider: data.llmProvider, diff --git a/src/app/api/node/favicon/route.ts b/src/app/api/node/favicon/route.ts new file mode 100644 index 0000000..e93f28f --- /dev/null +++ b/src/app/api/node/favicon/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { db, nodes } from '@/db'; +import { eq } from 'drizzle-orm'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 500 }); + } + + const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + + // 1. Try exact match + let node = await db.query.nodes.findFirst({ + where: eq(nodes.domain, domain), + }); + + // 2. Fallback: If not found, check if there is exactly ONE node in the system + if (!node) { + const allNodes = await db.query.nodes.findMany({ limit: 2 }); + if (allNodes.length === 1) { + node = allNodes[0]; + } + } + + // Check if we have favicon data + if (!node?.faviconData) { + return NextResponse.json({ error: 'Favicon not found' }, { status: 404 }); + } + + // Parse the data URL to extract MIME type and base64 data + const dataUrl = node.faviconData; + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + + if (!match) { + // If not a proper data URL, try to serve as-is (backward compatibility) + return NextResponse.json({ error: 'Invalid favicon data' }, { status: 500 }); + } + + const mimeType = match[1]; + const base64Data = match[2]; + const buffer = Buffer.from(base64Data, 'base64'); + + // Return the image with proper headers + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', + 'Content-Length': buffer.length.toString(), + }, + }); + + } catch (error) { + console.error('Favicon serve error:', error); + return NextResponse.json({ error: 'Failed to serve favicon' }, { status: 500 }); + } +} diff --git a/src/app/api/node/logo/route.ts b/src/app/api/node/logo/route.ts new file mode 100644 index 0000000..e803069 --- /dev/null +++ b/src/app/api/node/logo/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { db, nodes } from '@/db'; +import { eq } from 'drizzle-orm'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 500 }); + } + + const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + + // 1. Try exact match + let node = await db.query.nodes.findFirst({ + where: eq(nodes.domain, domain), + }); + + // 2. Fallback: If not found, check if there is exactly ONE node in the system + if (!node) { + const allNodes = await db.query.nodes.findMany({ limit: 2 }); + if (allNodes.length === 1) { + node = allNodes[0]; + } + } + + // Check if we have logo data + if (!node?.logoData) { + return NextResponse.json({ error: 'Logo not found' }, { status: 404 }); + } + + // Parse the data URL to extract MIME type and base64 data + const dataUrl = node.logoData; + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + + if (!match) { + // If not a proper data URL, try to serve as-is (backward compatibility) + return NextResponse.json({ error: 'Invalid logo data' }, { status: 500 }); + } + + const mimeType = match[1]; + const base64Data = match[2]; + const buffer = Buffer.from(base64Data, 'base64'); + + // Return the image with proper headers + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', + 'Content-Length': buffer.length.toString(), + }, + }); + + } catch (error) { + console.error('Logo serve error:', error); + return NextResponse.json({ error: 'Failed to serve logo' }, { status: 500 }); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b5c7fdc..8202bd0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -577,8 +577,36 @@ export default function LoginPage() { -
- You own your storage. We just connect to it. +
+ + + {/* Storage Explainer Section */} +
+
+ 🗄️ Why do I need to provide storage? +
+
+ Synapsis is decentralized — unlike other platforms, we don't store your photos, videos, or files on our servers. + Instead, you connect your own S3-compatible storage bucket (like Cloudflare R2 or Backblaze B2) where your media lives. +
+
+
+ ✓ You own your data
+ Your files stay in your bucket, not ours +
+
+ ✓ Portable
+ Take your media with you if you leave +
+
+ ✓ Free options
+ R2 and B2 offer 10GB free
diff --git a/src/app/settings/security/page.tsx b/src/app/settings/security/page.tsx index 3fbfba6..2a07b1a 100644 --- a/src/app/settings/security/page.tsx +++ b/src/app/settings/security/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { ArrowLeftIcon } from '@/components/Icons'; -import { Shield, Lock, Check, AlertCircle } from 'lucide-react'; +import { Shield, Lock, Check, AlertCircle, Mail, Trash2 } from 'lucide-react'; import { useAuth } from '@/lib/contexts/AuthContext'; export default function SecuritySettingsPage() { @@ -13,7 +13,21 @@ export default function SecuritySettingsPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const { isIdentityUnlocked, signUserAction } = useAuth(); + const { isIdentityUnlocked, signUserAction, user } = useAuth(); + + // Email change states + const [newEmail, setNewEmail] = useState(''); + const [emailPassword, setEmailPassword] = useState(''); + const [isChangingEmail, setIsChangingEmail] = useState(false); + const [emailError, setEmailError] = useState(null); + const [emailSuccess, setEmailSuccess] = useState(null); + + // Delete account states + const [deletePassword, setDeletePassword] = useState(''); + const [deleteConfirm, setDeleteConfirm] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -72,6 +86,97 @@ export default function SecuritySettingsPage() { } }; + const handleEmailChange = async (e: React.FormEvent) => { + e.preventDefault(); + setEmailError(null); + setEmailSuccess(null); + + if (!newEmail || !emailPassword) { + setEmailError('Please enter your new email and current password'); + return; + } + + if (!isIdentityUnlocked) { + setEmailError('Your session has expired. Please log in again.'); + return; + } + + setIsChangingEmail(true); + + try { + const signedPayload = await signUserAction('change_email', { + newEmail, + currentPassword: emailPassword + }); + + const res = await fetch('/api/account/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedPayload), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'Failed to change email'); + } + + setEmailSuccess('Email updated successfully'); + setNewEmail(''); + setEmailPassword(''); + } catch (error) { + setEmailError(error instanceof Error ? error.message : 'An error occurred'); + } finally { + setIsChangingEmail(false); + } + }; + + const handleDeleteAccount = async (e: React.FormEvent) => { + e.preventDefault(); + setDeleteError(null); + + if (deleteConfirm !== 'DELETE') { + setDeleteError('Please type DELETE to confirm account deletion'); + return; + } + + if (!deletePassword) { + setDeleteError('Please enter your password to confirm'); + return; + } + + if (!isIdentityUnlocked) { + setDeleteError('Your session has expired. Please log in again.'); + return; + } + + setIsDeleting(true); + + try { + const signedPayload = await signUserAction('delete_account', { + password: deletePassword + }); + + const res = await fetch('/api/account/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedPayload), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'Failed to delete account'); + } + + // Account deleted - redirect to home + window.location.href = '/'; + } catch (error) { + setDeleteError(error instanceof Error ? error.message : 'An error occurred'); + setIsDeleting(false); + } + }; + return (
+ + {/* Change Email Section */} +
+
+
+ +
+
+

Change Email

+

+ Update your email address +

+
+
+ +
+
+ + +
+ +
+ + setNewEmail(e.target.value)} + placeholder="Enter new email address" + required + /> +
+ +
+ + setEmailPassword(e.target.value)} + placeholder="Enter your current password" + required + /> +
+ + {emailError && ( +
+ + {emailError} +
+ )} + + {emailSuccess && ( +
+ + {emailSuccess} +
+ )} + + +
+
+ + {/* Delete Account Section */} +
+
+
+ +
+
+

Delete Account

+

+ Permanently delete your account and all data +

+
+
+ + {!showDeleteConfirm ? ( +
+
+ Warning: This action cannot be undone. + Your account, posts, and all associated data will be permanently deleted. + Your handle will be released and may be claimed by someone else. +
+ +
+ ) : ( +
+
+ This is permanent. Type DELETE below to confirm. +
+ +
+ + setDeleteConfirm(e.target.value)} + placeholder="DELETE" + required + /> +
+ +
+ + setDeletePassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+ + {deleteError && ( +
+ + {deleteError} +
+ )} + +
+ + +
+
+ )} +
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7a50d3b..d42cc3a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,11 +7,11 @@ import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/contexts/AuthContext'; import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons'; import { formatFullHandle } from '@/lib/utils/handle'; -import { LogOut, Settings2, Unlock } from 'lucide-react'; +import { LogOut, Settings2 } from 'lucide-react'; // import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper export function Sidebar() { - const { user, isAdmin, logout, isIdentityUnlocked, lockIdentity } = useAuth(); + const { user, isAdmin, logout } = useAuth(); const pathname = usePathname(); const router = useRouter(); const [customLogoUrl, setCustomLogoUrl] = useState(undefined); @@ -192,55 +192,6 @@ export function Sidebar() { - {/* Identity Status - Only show when unlocked with option to lock */} - {isIdentityUnlocked && ( -
lockIdentity()} - title="Click to lock your identity" - style={{ - display: 'flex', - alignItems: 'center', - gap: '8px', - padding: '10px 12px', - marginBottom: '12px', - borderRadius: '8px', - background: 'rgba(34, 197, 94, 0.1)', - border: '1px solid rgba(34, 197, 94, 0.2)', - cursor: 'pointer', - transition: 'all 0.2s', - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = 'rgba(34, 197, 94, 0.15)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)'; - }} - > - -
-
- Identity Unlocked -
-
- Click to lock -
-
-
- )} -