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) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-21 22:41:28 +00:00
parent 585a9503ab
commit a8b75593c2
11 changed files with 895 additions and 101 deletions

View File

@@ -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<string | null>(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() {
)}
</div>
</div>
<button
onClick={() => setShowNewThread(!showNewThread)}
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
>
+ New Thread
</button>
{isAuthenticated ? (
<button
onClick={() => setShowNewThread(!showNewThread)}
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
>
+ New Thread
</button>
) : (
<Link
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
>
Sign in to post
</Link>
)}
</div>
</div>
</div>
<div className="max-w-container mx-auto px-4 py-6">
{/* New Thread Form */}
{showNewThread && (
{/* New Thread Form — auth-gated */}
{showNewThread && isAuthenticated && (
<form onSubmit={handleNewThread} className="bg-[#1a2535] border border-[#2a3a50] rounded-lg p-5 mb-6">
<h3 className="text-white font-heading font-semibold mb-4">Start a New Thread</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Your name (optional)"
value={newAuthor}
onChange={e => 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]"
/>
<input
type="text"
placeholder="Thread title *"
@@ -321,6 +336,11 @@ export default function ForumCategoryPage() {
rows={5}
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] resize-none"
/>
{newError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{newError}
</div>
)}
<div className="flex gap-3">
<button
type="submit"