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 (
<>