From a8b75593c22d32380cb13c3d70bade7de7d6447a Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Thu, 21 May 2026 22:41:28 +0000 Subject: [PATCH] forum: require auth for posting + add login/register/edit pages - API: require auth on thread+reply POST (401 if !user); lazy-create forum_user_profile via service-role; author_name = display_name - UI: surface API errors (kill silent catch); auto-redirect to login on 401; gate new-thread + reply forms with "Sign in to post" CTA - /forum/login + /forum/register: forum-themed Supabase Auth pages with ?next= redirect support - /forum/profile/edit + /api/forum/profile/me: edit own forum profile (username, display_name, bio, role, company, location, avatar) - Header: logout button + sign-in/join links when not authed - forum/user/[username]: show Edit Profile button when viewing own Fixes posting bug: RLS rejected anon inserts with cryptic message that the UI silently swallowed; now blocked with friendly 401 + redirect. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/forum/profile/me/route.ts | 74 ++++++++ src/app/api/forum/replies/route.ts | 68 ++++---- src/app/api/forum/threads/route.ts | 68 ++++---- src/app/forum/[slug]/page.tsx | 60 ++++--- src/app/forum/login/page.tsx | 126 +++++++++++++ src/app/forum/profile/edit/page.tsx | 233 +++++++++++++++++++++++++ src/app/forum/register/page.tsx | 180 +++++++++++++++++++ src/app/forum/thread/[id]/page.tsx | 55 ++++-- src/app/forum/user/[username]/page.tsx | 29 ++- src/components/Header.tsx | 30 +++- src/lib/forum/profile.ts | 73 ++++++++ 11 files changed, 895 insertions(+), 101 deletions(-) create mode 100644 src/app/api/forum/profile/me/route.ts create mode 100644 src/app/forum/login/page.tsx create mode 100644 src/app/forum/profile/edit/page.tsx create mode 100644 src/app/forum/register/page.tsx create mode 100644 src/lib/forum/profile.ts diff --git a/src/app/api/forum/profile/me/route.ts b/src/app/api/forum/profile/me/route.ts new file mode 100644 index 0000000..f32eab1 --- /dev/null +++ b/src/app/api/forum/profile/me/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { ensureForumProfile } from '@/lib/forum/profile'; + +const EDITABLE_FIELDS = [ + 'username', + 'display_name', + 'bio', + 'role_title', + 'company', + 'location_city', + 'location_country', + 'avatar_url', +] as const; + +type EditableField = (typeof EDITABLE_FIELDS)[number]; + +export async function GET() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Not authenticated', auth_required: true }, { status: 401 }); + } + const profile = await ensureForumProfile(user); + return NextResponse.json({ profile }); +} + +export async function PATCH(req: NextRequest) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Not authenticated', auth_required: true }, { status: 401 }); + } + + const body = await req.json(); + const updates: Partial> = {}; + for (const field of EDITABLE_FIELDS) { + if (field in body) { + const v = body[field]; + updates[field] = typeof v === 'string' ? v.trim() || null : null; + } + } + + if (updates.username) { + const u = updates.username; + if (!/^[a-z0-9_]{3,24}$/i.test(u)) { + return NextResponse.json( + { error: 'Username must be 3-24 characters, letters/numbers/underscore only.' }, + { status: 400 } + ); + } + updates.username = u.toLowerCase(); + } + + await ensureForumProfile(user); + + const admin = createAdminClient(); + const { data: updated, error } = await admin + .from('forum_user_profiles') + .update(updates) + .eq('user_id', user.id) + .select() + .single(); + + if (error) { + if (error.code === '23505') { + return NextResponse.json({ error: 'That username is already taken.' }, { status: 409 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ profile: updated }); +} diff --git a/src/app/api/forum/replies/route.ts b/src/app/api/forum/replies/route.ts index 31bf933..4a77e8a 100644 --- a/src/app/api/forum/replies/route.ts +++ b/src/app/api/forum/replies/route.ts @@ -1,54 +1,62 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { ensureForumProfile } from '@/lib/forum/profile'; export async function POST(req: NextRequest) { try { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json( + { error: 'You must be signed in to post a reply.', auth_required: true }, + { status: 401 } + ); + } + const body = await req.json(); - const { thread_id, body: replyBody, author_name } = body; + const { thread_id, body: replyBody } = body; if (!thread_id || !replyBody) { return NextResponse.json({ error: 'thread_id and body are required' }, { status: 400 }); } - // Check if authenticated user is suspended or banned - if (user) { - const { data: suspension } = await supabase - .from('user_suspensions') - .select('suspension_type, reason, expires_at') - .eq('user_id', user.id) - .eq('is_active', true) - .or('expires_at.is.null,expires_at.gt.' + new Date().toISOString()) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); + const { data: suspension } = await supabase + .from('user_suspensions') + .select('suspension_type, reason, expires_at') + .eq('user_id', user.id) + .eq('is_active', true) + .or('expires_at.is.null,expires_at.gt.' + new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); - if (suspension) { - const isBanned = suspension.suspension_type === 'banned'; - const expiryText = suspension.expires_at - ? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` - : ''; - return NextResponse.json( - { - error: isBanned - ? `Your account has been permanently banned. Reason: ${suspension.reason}` - : `Your account is suspended${expiryText}. Reason: ${suspension.reason}`, - suspended: true, - suspension_type: suspension.suspension_type, - }, - { status: 403 } - ); - } + if (suspension) { + const isBanned = suspension.suspension_type === 'banned'; + const expiryText = suspension.expires_at + ? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` + : ''; + return NextResponse.json( + { + error: isBanned + ? `Your account has been permanently banned. Reason: ${suspension.reason}` + : `Your account is suspended${expiryText}. Reason: ${suspension.reason}`, + suspended: true, + suspension_type: suspension.suspension_type, + }, + { status: 403 } + ); } + const forumProfile = await ensureForumProfile(user); + const { data, error } = await supabase .from('forum_replies') .insert({ thread_id, body: replyBody.trim(), - author_id: user?.id || null, - author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'), + author_id: user.id, + author_name: forumProfile.display_name || forumProfile.username, is_ai_response: false, }) .select() diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts index 6f06733..d30d901 100644 --- a/src/app/api/forum/threads/route.ts +++ b/src/app/api/forum/threads/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { ensureForumProfile } from '@/lib/forum/profile'; export async function GET(req: NextRequest) { try { @@ -59,43 +60,50 @@ export async function POST(req: NextRequest) { try { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json( + { error: 'You must be signed in to start a thread.', auth_required: true }, + { status: 401 } + ); + } + const body = await req.json(); - const { category_id, title, body: threadBody, author_name } = body; + const { category_id, title, body: threadBody } = body; if (!category_id || !title || !threadBody) { return NextResponse.json({ error: 'category_id, title, and body are required' }, { status: 400 }); } - // Check if authenticated user is suspended or banned - if (user) { - const { data: suspension } = await supabase - .from('user_suspensions') - .select('suspension_type, reason, expires_at') - .eq('user_id', user.id) - .eq('is_active', true) - .or('expires_at.is.null,expires_at.gt.' + new Date().toISOString()) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); + const { data: suspension } = await supabase + .from('user_suspensions') + .select('suspension_type, reason, expires_at') + .eq('user_id', user.id) + .eq('is_active', true) + .or('expires_at.is.null,expires_at.gt.' + new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); - if (suspension) { - const isBanned = suspension.suspension_type === 'banned'; - const expiryText = suspension.expires_at - ? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` - : ''; - return NextResponse.json( - { - error: isBanned - ? `Your account has been permanently banned. Reason: ${suspension.reason}` - : `Your account is suspended${expiryText}. Reason: ${suspension.reason}`, - suspended: true, - suspension_type: suspension.suspension_type, - }, - { status: 403 } - ); - } + if (suspension) { + const isBanned = suspension.suspension_type === 'banned'; + const expiryText = suspension.expires_at + ? ` until ${new Date(suspension.expires_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` + : ''; + return NextResponse.json( + { + error: isBanned + ? `Your account has been permanently banned. Reason: ${suspension.reason}` + : `Your account is suspended${expiryText}. Reason: ${suspension.reason}`, + suspended: true, + suspension_type: suspension.suspension_type, + }, + { status: 403 } + ); } + const forumProfile = await ensureForumProfile(user); + // Fetch category name for better moderation context const { data: categoryData } = await supabase .from('forum_categories') @@ -109,8 +117,8 @@ export async function POST(req: NextRequest) { category_id, title: title.trim(), body: threadBody.trim(), - author_id: user?.id || null, - author_name: author_name?.trim() || (user ? 'Community Member' : 'Anonymous'), + author_id: user.id, + author_name: forumProfile.display_name || forumProfile.username, is_ai_seeded: false, }) .select() diff --git a/src/app/forum/[slug]/page.tsx b/src/app/forum/[slug]/page.tsx index 37ffd3d..732388e 100644 --- a/src/app/forum/[slug]/page.tsx +++ b/src/app/forum/[slug]/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import Header from '@/components/Header'; import Footer from '@/components/Footer'; +import { createClient } from '@/lib/supabase/client'; interface ForumCategory { id: string; @@ -173,8 +174,14 @@ export default function ForumCategoryPage() { const [showNewThread, setShowNewThread] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newBody, setNewBody] = useState(''); - const [newAuthor, setNewAuthor] = useState(''); const [submitting, setSubmitting] = useState(false); + const [newError, setNewError] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const supabase = createClient(); + supabase.auth.getUser().then(({ data: { user } }) => setIsAuthenticated(!!user)); + }, []); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -228,6 +235,7 @@ export default function ForumCategoryPage() { e.preventDefault(); if (!newTitle.trim() || !newBody.trim() || !category) return; setSubmitting(true); + setNewError(null); try { const res = await fetch('/api/forum/threads', { method: 'POST', @@ -236,19 +244,24 @@ export default function ForumCategoryPage() { category_id: category.id, title: newTitle, body: newBody, - author_name: newAuthor || 'Anonymous', }), }); const data = await res.json(); + if (!res.ok) { + if (res.status === 401) { + window.location.href = `/forum/login?next=${encodeURIComponent(window.location.pathname)}`; + return; + } + throw new Error(data.error || `Failed (${res.status})`); + } if (data.thread) { setThreads(prev => [data.thread, ...prev]); setNewTitle(''); setNewBody(''); - setNewAuthor(''); setShowNewThread(false); } - } catch { - // silent + } catch (err: any) { + setNewError(err.message || 'Failed to create thread. Please try again.'); } finally { setSubmitting(false); } @@ -282,29 +295,31 @@ export default function ForumCategoryPage() { )} - + {isAuthenticated ? ( + + ) : ( + + Sign in to post + + )}
- {/* New Thread Form */} - {showNewThread && ( + {/* New Thread Form — auth-gated */} + {showNewThread && isAuthenticated && (

Start a New Thread

- setNewAuthor(e.target.value)} - className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" - /> + {newError && ( +
+ {newError} +
+ )}
+
+ No account yet?{' '} + + Create one + +
+ +
+ +