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:
Ryan Salazar
2026-05-29 19:22:14 +00:00
parent 0ede78fd35
commit 891f2dd341
6 changed files with 424 additions and 0 deletions

View 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 });
}