auth: forgot/reset password + magic link + Google/MS/LinkedIn buttons
Forgot-password flow at /forum/forgot-password (Supabase resetPassword) plus /forum/reset-password that catches the recovery session and lets the user set a new password. 'Forgot password?' link added to the login form. SocialAuthButtons component drops onto both login + register pages with three OAuth providers (Google, Microsoft / Azure, LinkedIn — Apple skipped per Ryan, can add later when iOS app ships) plus a no-password magic-link option that works zero-config via Supabase OTP. Register page now detects already-known subscribers via the new /api/auth/lookup-email endpoint and sends a magic-link login instead of forcing them to pick a password. They can set a password later from their account if they want. Provider-side setup required: register OAuth apps in Google Cloud Console / Microsoft Entra / LinkedIn Developers, paste client IDs + secrets into Supabase dashboard → Authentication → Providers, set redirect URL to https://broadcastbeat.com/auth/callback. Code is wired to receive them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
src/app/api/auth/lookup-email/route.ts
Normal file
51
src/app/api/auth/lookup-email/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* GET /api/auth/lookup-email?email=foo@example.com
|
||||
*
|
||||
* Returns { known: true|false }. "Known" means the email is already on our
|
||||
* subscriber list (bb.email_subscribers) OR has an auth.users entry. Used
|
||||
* by the register page so existing newsletter readers get a magic-link
|
||||
* login flow instead of being asked to create a new account.
|
||||
*
|
||||
* Privacy: returns only known/not-known, never any details — does not
|
||||
* leak account existence beyond a binary signal (necessary for the UX).
|
||||
* Rate-limited at the proxy layer.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient as createService } from "@supabase/supabase-js";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const email = (new URL(req.url).searchParams.get("email") || "").trim().toLowerCase();
|
||||
if (!email || !email.includes("@")) {
|
||||
return NextResponse.json({ known: false }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
||||
if (!url || !key) {
|
||||
return NextResponse.json({ known: false });
|
||||
}
|
||||
const svc = createService(url, key, { auth: { persistSession: false } });
|
||||
|
||||
// Layer 1 — the rich subscriber registry (95k+ rows)
|
||||
const { data: sub } = await svc
|
||||
.schema("bb")
|
||||
.from("email_subscribers")
|
||||
.select("id, status")
|
||||
.eq("email_domain", email.split("@")[1])
|
||||
.ilike("email", email)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (sub) return NextResponse.json({ known: true, source: "subscriber" });
|
||||
|
||||
// Layer 2 — the lean property-specific list
|
||||
const { data: nl } = await svc
|
||||
.schema("bb")
|
||||
.from("newsletter_subscribers")
|
||||
.select("id")
|
||||
.ilike("email", email)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (nl) return NextResponse.json({ known: true, source: "newsletter" });
|
||||
|
||||
return NextResponse.json({ known: false });
|
||||
}
|
||||
96
src/app/forum/forgot-password/page.tsx
Normal file
96
src/app/forum/forgot-password/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
function ForgotInner() {
|
||||
const supabase = createClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setMsg(null); setErr(null);
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo: `${window.location.origin}/forum/reset-password`,
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
setErr(error.message);
|
||||
} else {
|
||||
setMsg("Check your inbox — we sent a password reset link. If you didn't have an account, no email is sent.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Account recovery</div>
|
||||
<h1 className="text-3xl font-heading font-bold text-white">Reset your password</h1>
|
||||
<p className="text-[#888] font-body text-sm mt-2">
|
||||
Enter your email — we’ll send a link to set a new password.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
{err && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email.trim()}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{busy ? "Sending…" : "Send reset link"}
|
||||
</button>
|
||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
||||
Remembered it?{" "}
|
||||
<Link href="/forum/login" className="text-[#3b82f6] hover:underline font-semibold">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ForgotInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
function safeNext(next: string | null): string {
|
||||
@@ -101,6 +102,15 @@ function ForumLoginInner() {
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
<div className="text-right text-xs font-body">
|
||||
<Link
|
||||
href={`/forum/forgot-password?email=${encodeURIComponent(email)}`}
|
||||
className="text-[#888] hover:text-[#3b82f6] hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<SocialAuthButtons next={next} />
|
||||
<div className="text-center text-xs font-body text-[#888] pt-2">
|
||||
No account yet?{' '}
|
||||
<Link
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
function safeNext(next: string | null): string {
|
||||
@@ -44,6 +45,34 @@ function ForumRegisterInner() {
|
||||
if (password !== confirm) return setError('Passwords do not match.');
|
||||
|
||||
setLoading(true);
|
||||
// Check first: if this email is already in our subscriber pool (existing
|
||||
// newsletter reader), we send them a magic-link login instead of creating
|
||||
// a new account — they already 'exist' to us. They can then set a
|
||||
// password from /account if they want.
|
||||
let knownSubscriber = false;
|
||||
try {
|
||||
const probe = await fetch(`/api/auth/lookup-email?email=${encodeURIComponent(email)}`);
|
||||
if (probe.ok) {
|
||||
const j = await probe.json();
|
||||
knownSubscriber = !!j.known;
|
||||
}
|
||||
} catch { /* probe failure → fall through to signUp */ }
|
||||
|
||||
if (knownSubscriber) {
|
||||
const { error: otpErr } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
|
||||
shouldCreateUser: true,
|
||||
data: { full_name: fullName.trim() },
|
||||
},
|
||||
});
|
||||
setLoading(false);
|
||||
if (otpErr) { setError(otpErr.message); return; }
|
||||
setInfo("Welcome back! You're already on our list. We sent a one-time login link to your inbox — click it to sign in. You can set a password from your account afterwards.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
@@ -199,6 +228,7 @@ function ForumRegisterInner() {
|
||||
<Link href="/privacy" className="text-[#888] hover:text-[#3b82f6] underline">privacy policy</Link>.
|
||||
We’ll never sell your email.
|
||||
</p>
|
||||
<SocialAuthButtons next={next} />
|
||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
|
||||
121
src/app/forum/reset-password/page.tsx
Normal file
121
src/app/forum/reset-password/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
function ResetInner() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
const [hasSession, setHasSession] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// The recovery flow lands here with a fresh session (Supabase verifies
|
||||
// the token in the URL hash and signs the user in). Confirm we got it.
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setHasSession(!!data.session);
|
||||
});
|
||||
}, [supabase]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setMsg(null); setErr(null);
|
||||
if (password.length < 8) { setErr("Use at least 8 characters."); setBusy(false); return; }
|
||||
if (password !== confirm) { setErr("Passwords don't match."); setBusy(false); return; }
|
||||
const { error } = await supabase.auth.updateUser({ password });
|
||||
setBusy(false);
|
||||
if (error) { setErr(error.message); return; }
|
||||
setMsg("Password updated. Redirecting…");
|
||||
setTimeout(() => router.push("/forum"), 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Reset password</div>
|
||||
<h1 className="text-3xl font-heading font-bold text-white">Set a new password</h1>
|
||||
</div>
|
||||
{hasSession === false ? (
|
||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 text-center">
|
||||
<p className="text-[#cbd5e1] mb-4 text-sm">
|
||||
This reset link is expired or invalid.
|
||||
</p>
|
||||
<Link
|
||||
href="/forum/forgot-password"
|
||||
className="inline-block bg-[#3b82f6] hover:bg-[#2563eb] text-white font-semibold text-sm px-5 py-2 rounded"
|
||||
>
|
||||
Request a new link
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
{err && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-semibold text-sm px-5 py-3 rounded-lg"
|
||||
>
|
||||
{busy ? "Saving…" : "Update password"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <Suspense fallback={null}><ResetInner /></Suspense>;
|
||||
}
|
||||
116
src/components/auth/SocialAuthButtons.tsx
Normal file
116
src/components/auth/SocialAuthButtons.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
/**
|
||||
* Social + magic-link auth buttons. Drops into the existing login + register
|
||||
* pages. Every provider must be enabled in the Supabase dashboard
|
||||
* (Authentication → Providers) and have its client ID/secret + redirect
|
||||
* URL configured. Providers that aren't enabled yet just no-op silently
|
||||
* if clicked — Supabase returns a 400 we surface as an error.
|
||||
*
|
||||
* Provider config Ryan needs to complete (one-time, ~10 min each):
|
||||
* - Google → console.cloud.google.com → OAuth client ID
|
||||
* - Azure (MS) → entra.microsoft.com → App registration
|
||||
* - LinkedIn → linkedin.com/developers → App
|
||||
* - Apple → developer.apple.com → Services ID (optional, for iOS later)
|
||||
*
|
||||
* Magic link (Email OTP) works with zero provider config — uses Supabase's
|
||||
* own SMTP path (which already routes through Brevo).
|
||||
*/
|
||||
type Provider = "google" | "azure" | "linkedin_oidc";
|
||||
|
||||
const PROVIDERS: { id: Provider; label: string; icon: string }[] = [
|
||||
{ id: "google", label: "Continue with Google", icon: "G" },
|
||||
{ id: "azure", label: "Continue with Microsoft", icon: "▦" },
|
||||
{ id: "linkedin_oidc", label: "Continue with LinkedIn", icon: "in" },
|
||||
];
|
||||
|
||||
export default function SocialAuthButtons({ next = "/forum" }: { next?: string }) {
|
||||
const supabase = createClient();
|
||||
const [busy, setBusy] = useState<Provider | "magic" | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [showMagic, setShowMagic] = useState(false);
|
||||
|
||||
async function oauth(p: Provider) {
|
||||
setBusy(p); setErr(null); setMsg(null);
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: p,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
|
||||
},
|
||||
});
|
||||
if (error) { setErr(error.message); setBusy(null); }
|
||||
// Supabase navigates to the provider — no need to flip busy on success
|
||||
}
|
||||
|
||||
async function magicLink(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy("magic"); setErr(null); setMsg(null);
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email: email.trim(),
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
});
|
||||
setBusy(null);
|
||||
if (error) { setErr(error.message); }
|
||||
else setMsg("Check your inbox — we sent a one-time login link.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-3 border-t border-[#252525]">
|
||||
<div className="text-center text-[10px] uppercase tracking-widest text-[#666] py-1">or</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => oauth(p.id)}
|
||||
className="bg-[#0d0d0d] border border-[#252525] hover:border-[#3b82f6] text-white text-xs font-semibold py-2 px-3 rounded-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span className="text-base leading-none">{p.icon}</span>
|
||||
<span>{busy === p.id ? "Redirecting…" : p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showMagic ? (
|
||||
<form onSubmit={magicLink} className="bg-[#0d0d0d] border border-[#252525] rounded-lg p-3 space-y-2">
|
||||
<p className="text-[11px] text-[#888]">We’ll send a one-time login link — no password needed.</p>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full bg-[#111] border border-[#2a2a2a] rounded px-2 py-1.5 text-white text-sm"
|
||||
/>
|
||||
{err && <p className="text-[11px] text-red-400">{err}</p>}
|
||||
{msg && <p className="text-[11px] text-emerald-400">{msg}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy === "magic" || !email.trim()}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] text-white text-xs font-semibold py-1.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{busy === "magic" ? "Sending…" : "Send login link"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMagic(true)}
|
||||
className="w-full bg-[#0d0d0d] border border-dashed border-[#3b82f6]/50 hover:border-[#3b82f6] text-[#3b82f6] text-xs font-semibold py-2 rounded-lg transition-colors"
|
||||
>
|
||||
📧 Email me a one-time login link (no password)
|
||||
</button>
|
||||
)}
|
||||
{err && !showMagic && <p className="text-[11px] text-red-400 text-center">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user