From cb70a84dd560166a72edb73c855bfe84fd19c97b Mon Sep 17 00:00:00 2001 From: gosangam Date: Tue, 2 Jun 2026 18:37:38 +0530 Subject: [PATCH] feat: add password reset email --- app/(app)/(auth)/layout.tsx | 34 ++++ app/(app)/(auth)/login/page.tsx | 2 - app/(app)/settings/page.tsx | 218 ++++++++++++++++++++++ app/(app)/u/me/layout.tsx | 13 +- app/(app)/u/me/settings/page.tsx | 43 ----- components/auth/change-password-modal.tsx | 201 ++++++++++++++++++++ components/auth/user-menu.tsx | 2 +- components/auth/verify-email-banner.tsx | 89 +++++++-- components/profile/profile-edit-form.tsx | 74 +++++++- components/profile/profile-header.tsx | 10 +- components/reading-settings.tsx | 26 ++- lib/auth.ts | 38 +++- 12 files changed, 662 insertions(+), 88 deletions(-) create mode 100644 app/(app)/settings/page.tsx delete mode 100644 app/(app)/u/me/settings/page.tsx create mode 100644 components/auth/change-password-modal.tsx diff --git a/app/(app)/(auth)/layout.tsx b/app/(app)/(auth)/layout.tsx index 2118911d..cc30d85f 100644 --- a/app/(app)/(auth)/layout.tsx +++ b/app/(app)/(auth)/layout.tsx @@ -1,13 +1,47 @@ +"use client"; + /** * Shared layout for the auth pages (login, signup, forgot-password, * reset-password). Provides the centered card frame so each page only * has to render its own form. + * + * Also gates the routes: a signed-in user landing on any /login, /signup, + * etc. URL is bounced to /u/me (or the `next` query param if present). + * The check happens client-side because auth state lives in + * AuthProvider — there's no server-side cookie inspection here yet, and + * the redirect is purely cosmetic (logged-in users don't gain anything + * from re-seeing these forms, and signing in over an existing session + * would just no-op). */ + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/components/auth/auth-provider"; + export default function AuthLayout({ children, }: { children: React.ReactNode; }) { + const { user, loading } = useAuth(); + const router = useRouter(); + const params = useSearchParams(); + + useEffect(() => { + if (loading || !user) return; + const next = params.get("next"); + router.replace(next && next.startsWith("/") ? next : "/u/me"); + }, [loading, user, router, params]); + + // Don't flash the form for an instant before the redirect fires. + if (!loading && user) { + return ( +
+

Redirecting…

+
+ ); + } + return (
{children}
diff --git a/app/(app)/(auth)/login/page.tsx b/app/(app)/(auth)/login/page.tsx index 9a78f2a7..d5997580 100644 --- a/app/(app)/(auth)/login/page.tsx +++ b/app/(app)/(auth)/login/page.tsx @@ -37,8 +37,6 @@ function LoginContent() { function oauthErrorMessage(code: string): string { switch (code) { - case "email_required": - return "We couldn't sign you in because the provider didn't share an email. Please use the email/password form below."; case "invalid_state": case "missing_state": return "Your sign-in link expired. Please try again."; diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 00000000..546a9a8d --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +/** + * /settings — the top-level user-settings screen. + * + * Lives outside the /u/me profile layout so it doesn't carry the + * ProfileHeader + tabs that frame the profile views. It sits directly + * under (app)/, which gives it the default site chrome (sidebar, header, + * etc.) without the profile-specific frame on top. + * + * Sections are stacked plain — no per-section card chrome — so the page + * reads as a single ordered list of preferences rather than a dashboard + * of widgets. To add a new section, drop another
in the + * `
` below. + */ + +import { useState } from "react"; +import Link from "next/link"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChangePasswordModal } from "@/components/auth/change-password-modal"; +import { ReadingSettings } from "@/components/reading-settings"; + +const BFF_BASE = + process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; + +export default function SettingsPage() { + return ( +
+
+ + + Back to profile + +

+ Settings +

+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
{children}
+
+ ); +} + +// ─── Authentication ──────────────────────────────────────────────────── + +function AuthenticationSection() { + const [pwModalOpen, setPwModalOpen] = useState(false); + const next = "/settings"; + + function startOAuth(provider: "facebook" | "google") { + const u = new URL(`${BFF_BASE}/api/v1/auth/oauth/${provider}/start`); + u.searchParams.set("next", next); + return u.toString(); + } + + return ( + <> +
    + {/* Google sign-in — uncomment once the Google OAuth app clears review. + + Connect + + } + /> + */} + } + label="Connect your Facebook account" + action={ + + Connect + + } + /> + setPwModalOpen(true)} + className="text-sm font-medium text-primary hover:underline" + > + Change + + } + /> +
+ setPwModalOpen(false)} + /> + + ); +} + +// ─── Legal ───────────────────────────────────────────────────────────── + +function LegalSection() { + return ( +
    + + + +
+ ); +} + +function LegalLink({ href, label }: { href: string; label: string }) { + return ( +
  • + + + {label} + + + +
  • + ); +} + +// ─── Row helper ──────────────────────────────────────────────────────── + +function FacebookGlyph() { + return ( + + + + ); +} + +function Row({ + icon, + label, + description, + action, +}: { + icon?: React.ReactNode; + label: string; + description?: string; + action: React.ReactNode; +}) { + return ( +
  • +
    +
    + {icon ? {icon} : null} + {label} +
    + {description ? ( +

    {description}

    + ) : null} +
    +
    {action}
    +
  • + ); +} + diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx index 204c45d2..504de606 100644 --- a/app/(app)/u/me/layout.tsx +++ b/app/(app)/u/me/layout.tsx @@ -14,7 +14,7 @@ */ import { Suspense, useEffect } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { AtSign, Bookmark, @@ -102,13 +102,8 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { function ConditionalTabs({ tabs }: { tabs: ProfileTab[] }) { const searchParams = useSearchParams(); - const pathname = usePathname(); - // Hide tabs when the user is in a focused subview: - // • /u/me?edit=1 — the inline profile-edit form - // • /u/me/settings — the reading-prefs page - // In either case the strip is visual noise that distracts from the - // task they navigated in to do. + // Hide tabs when the user is editing their profile inline — the strip + // is visual noise that distracts from the task they navigated in to do. const editMode = searchParams.get("edit") === "1"; - const settingsMode = pathname.startsWith("/u/me/settings"); - return editMode || settingsMode ? null : ; + return editMode ? null : ; } diff --git a/app/(app)/u/me/settings/page.tsx b/app/(app)/u/me/settings/page.tsx deleted file mode 100644 index b3b83d80..00000000 --- a/app/(app)/u/me/settings/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -/** - * /u/me/settings — dedicated reading-preferences screen for auth users. - * - * Mirrors the Edit Profile pattern: a focused subview of /u/me that - * trades the tab strip for a back link + the settings form. We send - * users here instead of opening the popover because the popover, when - * triggered from ProfileHeader's centred action row, can spill off the - * left edge of narrow viewports. Anonymous users still get the popover - * in SiteHeader — that trigger sits at right-2 so it doesn't overflow. - * - * The /u/me layout's ConditionalTabs detects this path and hides the - * Posts/Collections/Bookmarks/Mentions strip while the user is editing - * settings. - */ - -import Link from "next/link"; -import { ChevronLeft } from "lucide-react"; -import { ReadingSettings } from "@/components/reading-settings"; - -export default function ReadingSettingsPage() { - return ( -
    - - - Back to profile - -
    -

    - Reading settings -

    -

    - Theme, palette, font, and size — synced across your devices. -

    -
    - -
    - ); -} diff --git a/components/auth/change-password-modal.tsx b/components/auth/change-password-modal.tsx new file mode 100644 index 00000000..6cd9a773 --- /dev/null +++ b/components/auth/change-password-modal.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * ChangePasswordModal — three-field "change my password" form rendered in + * a centred portal dialog. Mirrors the AuthModal pattern (backdrop + + * fixed-position card + Esc-to-close + body scroll-lock) so the look and + * feel matches. + * + * Opens from /settings → Authentication → Change password. + * + * On success: closes itself, refreshes the user state via auth-provider's + * setUser so the rest of the app sees the fresh JWT-backed user. The BFF + * has already re-issued the session cookie, so the caller stays signed in + * on this device. + */ + +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 ChangePasswordModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { user, setUser } = useAuth(); + // OAuth-only accounts have no existing password to verify against — the + // modal renders two fields (new + confirm) instead of three. Backend + // accepts current_password as optional in this case. + const requireCurrent = user?.has_password ?? true; + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Reset form whenever the modal closes — re-opening should start blank, + // not show the previous attempt. + useEffect(() => { + if (open) return; + setCurrent(""); + setNext(""); + setConfirm(""); + 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 the modal is 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); + if (next.length < 8) { + setError("New password must be at least 8 characters."); + return; + } + if (next !== confirm) { + setError("New passwords don't match."); + return; + } + setSubmitting(true); + try { + const updated = await auth.changePassword(current, next); + setUser(updated); + onClose(); + } catch (err) { + setError( + err instanceof AuthError ? err.message : "Could not change password.", + ); + } finally { + setSubmitting(false); + } + } + + return createPortal( + <> +
    +
    +
    +
    +

    + Change password +

    + +
    + +
    + {requireCurrent ? ( +
    + + setCurrent(e.target.value)} + /> +
    + ) : null} +
    + + setNext(e.target.value)} + /> +

    + At least 8 characters. +

    +
    +
    + + setConfirm(e.target.value)} + /> +
    + + {error ? ( +

    {error}

    + ) : null} + +
    + + +
    +
    +
    +
    + , + document.body, + ); +} diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx index aa723f02..4ccf0845 100644 --- a/components/auth/user-menu.tsx +++ b/components/auth/user-menu.tsx @@ -138,7 +138,7 @@ export function UserMenu() { {user.name}

    - {user.email} + {user.email ?? `@${user.handle}`}

    diff --git a/components/auth/verify-email-banner.tsx b/components/auth/verify-email-banner.tsx index 45b791e9..e9662011 100644 --- a/components/auth/verify-email-banner.tsx +++ b/components/auth/verify-email-banner.tsx @@ -5,27 +5,34 @@ import { auth, AuthError } from "@/lib/auth"; import { useAuth } from "./auth-provider"; /** - * Persistent top ribbon shown when a logged-in user has not yet verified - * their email. Lets them re-trigger the verification mail without leaving - * the page. + * Top ribbon for accounts whose email is either missing (OAuth sign-up + * 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. * * Renders nothing when: * - auth state is still loading * - user is logged out - * - user.email_verified_at is set - * - user dismissed it for this session (banner is meant to be persistent - * across navigations but skippable per-tab; we keep the dismissed flag - * in component state so a refresh brings it back) + * - user.email_verified_at is set (the address has been confirmed) + * - user dismissed it for this session (persistent across navigations + * but reset on full page refresh) */ export function VerifyEmailBanner() { - const { user, loading } = useAuth(); + const { user, loading, setUser } = 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(""); if (loading || !user || user.email_verified_at || dismissed) return null; + const needsEmail = !user.email; + async function onResend() { setError(null); setBusy(true); @@ -41,18 +48,68 @@ 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 - ? "✓ Verification email sent — check your inbox." - : error - ? error - : `Verify your email (${user.email}) to secure your account.`} -

    + {sent ? ( +

    + {needsEmail + ? "✓ Email saved — check your inbox for a verification link." + : "✓ Verification email sent — check your inbox."} +

    + ) : 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} + + ) : null} +
    + ) : ( +

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

    + )} +
    - {!sent ? ( + {!sent && !needsEmail ? (
    @@ -202,6 +208,66 @@ export function ProfileEditForm({ ); } +function AddEmailInline() { + const { setUser } = useAuth(); + const [email, setEmail] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [sent, setSent] = useState(false); + + async function onSave(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSaving(true); + try { + const updated = await auth.setEmail(email); + setUser(updated); + setSent(true); + } catch (err) { + setError(err instanceof AuthError ? err.message : "Could not save email."); + } finally { + setSaving(false); + } + } + + if (sent) { + return ( +

    + ✓ Email saved — check your inbox for a verification link. +

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

    {error}

    + ) : ( +

    + Add an email to enable password recovery and notifications. +

    + )} +
    + ); +} + function AvatarPreview({ src, name }: { src: string | null; name: string }) { if (src) { return ( diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index bf5e3c2a..9ac8e673 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -12,8 +12,8 @@ * "is this my profile?" signal (the /u/me layout passes it; the * /u/[handle] layout does not), so the two buttons stay in lockstep * and visitors on someone else's profile see a clean row with no - * personal-settings affordances. The gear links to /u/me/settings - * (the inline reading-prefs page) rather than opening a popover — + * personal-settings affordances. The gear links to /settings (the + * dedicated settings page) rather than opening a popover — * centred-row popovers tend to spill off the left edge on narrow * viewports. * @@ -84,9 +84,9 @@ export function ProfileHeader({ ) : null} {showReadingSettings ? ( p.id === activePaletteId)?.name ?? ""; if (!mounted) { - // Inline variant: render nothing until mounted so we don't flash a - // misaligned skeleton on the dedicated settings page. - if (variant === "inline") return null; + // Inline / bare variants: render nothing until mounted so we don't + // flash a misaligned skeleton on the dedicated settings page. + if (variant === "inline" || variant === "bare") return null; return (