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
+
+
+ );
+}
+
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(
+ <>
+
+
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 ? (
+
+ ) : (
+
+ {error ?? `Verify your email (${user.email}) to secure your account.`}
+
+ ✓ Email saved — check your inbox for a verification link.
+
+ );
+ }
+
+ return (
+
+ );
+}
+
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 (