From ec67ddf852e2b1a6aeaaa3b93dcc8af3e7d0ef9a Mon Sep 17 00:00:00 2001 From: gosangam Date: Tue, 2 Jun 2026 22:55:09 +0530 Subject: [PATCH] feat: social auth account linking --- app/(app)/settings/page.tsx | 303 ++++++++++++++++++++---- components/auth/add-email-modal.tsx | 161 +++++++++++++ components/auth/auth-provider.tsx | 46 +++- components/auth/login-form.tsx | 169 +++++++++---- components/auth/verify-email-banner.tsx | 126 ++++------ lib/auth.ts | 47 +++- 6 files changed, 675 insertions(+), 177 deletions(-) create mode 100644 components/auth/add-email-modal.tsx diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index decc59c9..37ff5c89 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -14,9 +14,19 @@ * `
` below. */ -import { useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import Link from "next/link"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + AlertCircle, + Check, + ChevronLeft, + ChevronRight, + LogOut, +} from "lucide-react"; +import { auth, AuthError } from "@/lib/auth"; +import { useAuth } from "@/components/auth/auth-provider"; +import { AddEmailModal } from "@/components/auth/add-email-modal"; import { ChangePasswordModal } from "@/components/auth/change-password-modal"; import { ReadingSettings } from "@/components/reading-settings"; @@ -29,9 +39,9 @@ export default function SettingsPage() {
- + Back to profile

@@ -47,16 +57,56 @@ export default function SettingsPage() {
+ {/* Suspense boundary required because LinkResult reads + useSearchParams — Next.js bails out of SSR otherwise. */} + + +
+ +

); } +function SignOutSection() { + const { logOut } = useAuth(); + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function onSignOut() { + setBusy(true); + try { + await logOut(); + router.push("/"); + router.refresh(); + } catch { + // Logout errors are silent — the cookie is cleared on the server + // either way and the next protected request will redirect anyway. + setBusy(false); + } + } + + return ( +
+ +
+ ); +} + function Section({ title, description, @@ -69,11 +119,11 @@ function Section({ return (
-

+

{title}

{description ? ( -

{description}

+

{description}

) : null}
{children}
@@ -84,64 +134,226 @@ function Section({ // ─── Authentication ──────────────────────────────────────────────────── function AuthenticationSection() { + const { user } = useAuth(); const [pwModalOpen, setPwModalOpen] = useState(false); + const [emailModalOpen, setEmailModalOpen] = useState(false); + const [linkedProviders, setLinkedProviders] = useState | null>( + null, + ); const next = "/settings"; + // Fetch linked providers on mount. A null set means "not yet loaded" — we + // render the rows as if nothing is connected until the answer comes back, + // which is fine because the only thing that changes after load is the + // "Connected" pill swapping in for "Connect". + useEffect(() => { + let cancelled = false; + auth + .linkedProviders() + .then(({ providers }) => { + if (!cancelled) setLinkedProviders(new Set(providers)); + }) + .catch(() => { + if (!cancelled) setLinkedProviders(new Set()); + }); + return () => { + cancelled = true; + }; + }, []); + function startOAuth(provider: "facebook" | "google") { - const u = new URL(`${BFF_BASE}/api/v1/auth/oauth/${provider}/start`); + // /link/start — links the provider to the currently-authenticated + // user (auth cookie travels on the top-level navigation). The login + // page still uses /start for sign-in / sign-up. + const u = new URL( + `${BFF_BASE}/api/v1/auth/oauth/${provider}/link/start`, + ); u.searchParams.set("next", next); return u.toString(); } + function providerAction(provider: "google" | "facebook") { + if (linkedProviders?.has(provider)) { + return ; + } + return ( + + Connect + + ); + } + + // Three contextual states for the email-related rows: + // • user has no email → "Add your email" row only + // • user has email, NOT verified → "Verify your email" row + + // "Change your password" row + // • user has email, verified → "Change your password" row + const needsEmail = !user?.email; + const needsVerify = !!user?.email && !user.email_verified_at; + return ( <>
    } label="Connect your Google account" - action={ - - Connect - - } + action={providerAction("google")} /> } label="Connect your Facebook account" - action={ - - Connect - - } - /> - setPwModalOpen(true)} - className="text-sm font-medium text-primary hover:underline" - > - Change - - } + action={providerAction("facebook")} /> + {needsEmail ? ( + setEmailModalOpen(true)} + className="text-base font-medium text-primary hover:underline" + > + Add email + + } + /> + ) : ( + <> + {needsVerify ? : null} + setPwModalOpen(true)} + className="text-base font-medium text-primary hover:underline" + > + Change + + } + /> + + )}
setPwModalOpen(false)} /> + setEmailModalOpen(false)} + /> ); } +function VerifyEmailRow({ email }: { email: string }) { + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + async function onResend() { + setError(null); + setBusy(true); + try { + await auth.resendVerification(); + setSent(true); + } catch (err) { + setError( + err instanceof AuthError + ? err.message + : "Could not send verification email.", + ); + } finally { + setBusy(false); + } + } + + return ( + Sent + ) : ( + + ) + } + /> + ); +} + +function LinkResult() { + const params = useSearchParams(); + const router = useRouter(); + const raw = params.get("link"); + + // Strip the param from the URL so a refresh doesn't re-show the banner. + useEffect(() => { + if (!raw) return; + const next = new URLSearchParams(params.toString()); + next.delete("link"); + const q = next.toString(); + router.replace(q ? `/settings?${q}` : "/settings"); + }, [raw, params, router]); + + if (!raw) return null; + + // Format: ":" — `google:ok`, `facebook:claimed`, etc. + const [provider, status] = raw.split(":", 2); + const name = provider.charAt(0).toUpperCase() + provider.slice(1); + + if (status === "ok") { + return ( +

+ + Connected your {name} account. +

+ ); + } + if (status === "claimed") { + return ( +

+ + That {name} account is already linked to another Prabhupāda Says + account. +

+ ); + } + return ( +

+ + Couldn't connect your {name} account. Please try again. +

+ ); +} + +function ConnectedPill() { + return ( + + + Connected + + ); +} + // ─── Legal ───────────────────────────────────────────────────────────── function LegalSection() { @@ -159,12 +371,12 @@ function LegalLink({ href, label }: { href: string; label: string }) {
  • - + {label} - +
  • ); @@ -175,7 +387,7 @@ function LegalLink({ href, label }: { href: string; label: string }) { function GoogleGlyph() { return ( +
  • -
    +
    {icon ? {icon} : null} - {label} + {label}
    {description ? ( -

    {description}

    +

    {description}

    ) : null}
    {action}
  • ); } - diff --git a/components/auth/add-email-modal.tsx b/components/auth/add-email-modal.tsx new file mode 100644 index 00000000..ffc9273f --- /dev/null +++ b/components/auth/add-email-modal.tsx @@ -0,0 +1,161 @@ +"use client"; + +/** + * AddEmailModal — centred portal dialog with a single email input for + * users who signed up via OAuth and never shared an address. Mirrors + * the ChangePasswordModal pattern so the look and feel matches. + * + * On success: closes itself and updates the auth-provider's user so the + * rest of the app immediately sees the new email. The BFF stores the + * address as unverified and fires a verification link to it; the user + * sees that as the existing "verify your email" banner on the next + * render. + * + * Entry points: + * • VerifyEmailBanner — top-of-page "Add an email" button + * • /settings → Authentication → Add your email row + */ + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { useAuth } from "@/components/auth/auth-provider"; +import { auth, AuthError } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function AddEmailModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { setUser } = useAuth(); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Blank slate every time the modal opens. + useEffect(() => { + if (open) return; + setEmail(""); + setError(null); + setSubmitting(false); + }, [open]); + + // Esc-to-close. + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // Body scroll-lock while open. + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open || typeof document === "undefined") return null; + + async function onSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const updated = await auth.setEmail(email); + setUser(updated); + onClose(); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Could not save email."); + } finally { + setSubmitting(false); + } + } + + return createPortal( + <> +
    +
    +
    +
    +

    + Add your email +

    + +
    + +
    +

    + Add an email to enable password recovery and notifications. + We'll send a verification link to confirm it's yours. +

    +
    + + setEmail(e.target.value)} + placeholder="you@example.com" + /> +
    + + {error ? ( +

    {error}

    + ) : null} + +
    + + +
    +
    +
    +
    + , + document.body, + ); +} diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx index d1f3901a..44e0baed 100644 --- a/components/auth/auth-provider.tsx +++ b/components/auth/auth-provider.tsx @@ -21,11 +21,22 @@ import { auth, type User } from "@/lib/auth"; * After a successful login/signup/logout the provider re-fetches /auth/me * to keep state in sync with the cookie. */ +/** + * LogInResult is either a completed session (regular case) or a signal + * that the BFF wants a one-time code before completing the login. Callers + * should branch on `kind` and, for `otp_required`, present an OTP field + * and call `verifyLoginOTP`. + */ +export type LogInResult = + | { kind: "session"; user: User } + | { kind: "otp_required" }; + type AuthContextValue = { user: User | null; loading: boolean; signUp: (email: string, password: string, name: string) => Promise; - logIn: (email: string, password: string) => Promise; + logIn: (email: string, password: string) => Promise; + verifyLoginOTP: (email: string, password: string, otp: string) => Promise; logOut: () => Promise; refresh: () => Promise; /** Replace the user state without a network round-trip (after profile edit). */ @@ -58,19 +69,42 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const logIn = useCallback(async (email: string, password: string) => { - const u = await auth.logIn(email, password); - setUser(u); - return u; + const res = await auth.logIn(email, password); + if (res.kind === "session") { + setUser(res.user); + } + // For `otp_required` we deliberately leave `user` null so the rest of + // the app keeps showing the signed-out state until VerifyLoginOTP + // completes. + return res; }, []); + const verifyLoginOTP = useCallback( + async (email: string, password: string, otp: string) => { + const u = await auth.verifyLoginOTP(email, password, otp); + setUser(u); + return u; + }, + [], + ); + const logOut = useCallback(async () => { await auth.logOut(); setUser(null); }, []); const value = useMemo( - () => ({ user, loading, signUp, logIn, logOut, refresh, setUser }), - [user, loading, signUp, logIn, logOut, refresh], + () => ({ + user, + loading, + signUp, + logIn, + verifyLoginOTP, + logOut, + refresh, + setUser, + }), + [user, loading, signUp, logIn, verifyLoginOTP, logOut, refresh], ); return {children}; diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 1a89cf12..fb80c727 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -5,12 +5,16 @@ * the AuthGate modal that pops up when an unauthenticated user tries to use * a protected feature. * - * `onSuccess` is invoked once login completes. Callers decide what happens - * next (a page redirect, a modal close, etc.). The form itself doesn't - * navigate — that keeps it environment-agnostic. + * Two stages: + * 1. Password — email + password. If the BFF signals `otp_required` + * (account unverified for >30 days), the form advances to stage 2. + * 2. OTP — 6-digit code emailed to the user. Submitting verifies the + * code, marks the email verified, and completes the session. * - * `forgotPasswordHref` is configurable so the modal can override (it may - * want to dismiss itself or open a different mode). + * `onSuccess` fires after the session is finalised (either at stage 1 for + * verified accounts or stage 2 for the OTP-gated path). Callers decide + * what happens next (page redirect, modal close, etc.) — the form itself + * doesn't navigate. */ import { useState } from "react"; @@ -34,18 +38,26 @@ export function LoginForm({ /** Post-OAuth redirect path on the frontend. */ oauthNext?: string; }) { - const { logIn } = useAuth(); + const { logIn, verifyLoginOTP } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [otp, setOTP] = useState(""); + // Two-stage state machine. "password" is the default; we advance to + // "otp" only after the BFF signals otp_required. + const [stage, setStage] = useState<"password" | "otp">("password"); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - async function onSubmit(e: React.SyntheticEvent) { + async function onPasswordSubmit(e: React.SyntheticEvent) { e.preventDefault(); setError(null); setSubmitting(true); try { - await logIn(email, password); + const res = await logIn(email, password); + if (res.kind === "otp_required") { + setStage("otp"); + return; + } onSuccess?.(); } catch (err) { setError(err instanceof AuthError ? err.message : "Login failed."); @@ -54,46 +66,115 @@ export function LoginForm({ } } + async function onOTPSubmit(e: React.SyntheticEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await verifyLoginOTP(email, password, otp); + onSuccess?.(); + } catch (err) { + setError( + err instanceof AuthError ? err.message : "Could not verify the code.", + ); + } finally { + setSubmitting(false); + } + } + + if (stage === "otp") { + return ( +
    +
    + + + setOTP(e.target.value.replace(/\D/g, "").slice(0, 6)) + } + placeholder="123456" + className="tracking-[0.4em] text-center text-lg" + /> +

    + We sent a code to {email}. + Your account has been unverified for a while, so we need this + extra step. The code expires in 10 minutes. +

    +
    + {error ?

    {error}

    : null} + + +
    + ); + } + return (
    -
    -
    - - setEmail(e.target.value)} - /> -
    -
    -
    - - {showForgotLink ? ( - - Forgot? - - ) : null} + +
    + + setEmail(e.target.value)} + />
    - setPassword(e.target.value)} - /> -
    - {error ?

    {error}

    : null} - +
    +
    + + {showForgotLink ? ( + + Forgot? + + ) : null} +
    + setPassword(e.target.value)} + /> +
    + {error ?

    {error}

    : null} +
    ); diff --git a/components/auth/verify-email-banner.tsx b/components/auth/verify-email-banner.tsx index e9662011..512ac6ed 100644 --- a/components/auth/verify-email-banner.tsx +++ b/components/auth/verify-email-banner.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { auth, AuthError } from "@/lib/auth"; +import { AddEmailModal } from "./add-email-modal"; import { useAuth } from "./auth-provider"; /** @@ -9,10 +10,11 @@ import { useAuth } from "./auth-provider"; * without an address) or present-but-unverified. * * Two modes: - * • Missing email — an inline "Add your email" input that POSTs to - * /auth/email. On success the user state refreshes and the banner - * automatically flips into the "verify your email" mode. - * • Unverified email — existing "Resend verification" call to action. + * • Missing email — "Add an email" button that opens AddEmailModal. + * The modal POSTs to /auth/email and refreshes the user; on success + * this banner flips into the verify-your-email mode automatically. + * • Unverified email — "Resend verification" button calls + * auth.resendVerification(). * * Renders nothing when: * - auth state is still loading @@ -22,12 +24,12 @@ import { useAuth } from "./auth-provider"; * but reset on full page refresh) */ export function VerifyEmailBanner() { - const { user, loading, setUser } = useAuth(); + const { user, loading } = useAuth(); const [sent, setSent] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [dismissed, setDismissed] = useState(false); - const [newEmail, setNewEmail] = useState(""); + const [emailModalOpen, setEmailModalOpen] = useState(false); if (loading || !user || user.email_verified_at || dismissed) return null; @@ -48,87 +50,55 @@ export function VerifyEmailBanner() { } } - async function onSetEmail(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setBusy(true); - try { - const updated = await auth.setEmail(newEmail); - setUser(updated); - setSent(true); - } catch (err) { - setError(err instanceof AuthError ? err.message : "Could not save email."); - } finally { - setBusy(false); - } - } - return ( -
    -
    - {sent ? ( + <> +
    +

    - {needsEmail - ? "✓ Email saved — check your inbox for a verification link." - : "✓ Verification email sent — check your inbox."} + {sent + ? "✓ Verification email sent — check your inbox." + : error + ? error + : needsEmail + ? "Add an email to enable password recovery and notifications." + : `Verify your email (${user.email}) to secure your account.`}

    - ) : needsEmail ? ( -
    - - setNewEmail(e.target.value)} - placeholder="you@example.com" - className="flex-1 min-w-[12rem] rounded-md border border-amber-300/70 dark:border-amber-800/70 bg-white/70 dark:bg-amber-950/30 px-2 py-1 text-amber-900 dark:text-amber-50 placeholder:text-amber-700/50 dark:placeholder:text-amber-200/40" - /> - - {error ? ( - - {error} - +
    + {!sent ? ( + needsEmail ? ( + + ) : ( + + ) ) : null} - - ) : ( -

    - {error ?? `Verify your email (${user.email}) to secure your account.`} -

    - )} - -
    - {!sent && !needsEmail ? ( - ) : null} - +
    -
    + setEmailModalOpen(false)} + /> + ); } diff --git a/lib/auth.ts b/lib/auth.ts index 25e78b15..236f0ea3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -83,10 +83,43 @@ export const auth = { body: JSON.stringify({ email, password, name }), }), - logIn: (email: string, password: string) => - authFetch("/auth/login", { + /** + * Submits email/password. Three outcomes: + * - kind: "session" → fully signed in; auth-provider sets the user. + * - kind: "otp_required" → account has been unverified for >30 days; + * a 6-digit code was emailed. Caller must show an OTP form and call + * `verifyLoginOTP(email, password, otp)` to complete sign-in. + * Throws AuthError on credential / server errors. + */ + logIn: async ( + email: string, + password: string, + ): Promise< + | { kind: "session"; user: User } + | { kind: "otp_required" } + > => { + const data = await authFetch( + "/auth/login", + { + method: "POST", + body: JSON.stringify({ email, password }), + }, + ); + if (data && typeof data === "object" && "otp_required" in data) { + return { kind: "otp_required" }; + } + return { kind: "session", user: data as User }; + }, + + /** + * Completes a login that was paused by the OTP gate. Re-checks the + * password (the OTP alone isn't enough — defence in depth), validates + * the code, marks the email verified, and sets the session cookie. + */ + verifyLoginOTP: (email: string, password: string, otp: string) => + authFetch("/auth/verify-login-otp", { method: "POST", - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, otp }), }), logOut: () => authFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }), @@ -133,6 +166,14 @@ export const auth = { body: JSON.stringify({ email }), }), + /** + * Returns the list of OAuth provider names already linked to the + * signed-in user (e.g. `["google", "facebook"]`). The settings page + * uses this to flip per-provider rows between "Connect" / "Connected". + */ + linkedProviders: () => + authFetch<{ providers: string[] }>("/auth/linked-providers"), + /** * Replaces the signed-in user's password. The BFF verifies the current * password, hashes the new one, and re-issues the session cookie so this