Magic-link sign in UI and APIs

This commit is contained in:
adilallo
2026-04-06 16:37:15 -06:00
parent 331ed40234
commit 7218947df3
74 changed files with 1582 additions and 363 deletions
@@ -14,13 +14,14 @@ const Footer = dynamic(() => import("./Footer"), {
/**
* Conditionally renders Footer based on pathname.
* Hides footer for /create/* routes (full-screen create flow).
* Hides footer for /create/* and /login (full-screen flows; login uses a body portal).
*/
const ConditionalFooter = memo(() => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow) {
if (isCreateFlow || isLogin) {
return null;
}
@@ -1,24 +1,11 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
import { getNavAuthSignedIn } from "../../../lib/server/navAuth";
import ConditionalNavigationClient from "./ConditionalNavigationClient";
/**
* Conditionally renders TopNav based on pathname.
* Hides navigation for /create/* routes (full-screen create flow).
* Resolves the session on the server so the header matches the HttpOnly cookie on the
* first HTML response (no “Log in” flash before `/api/auth/session`).
*/
const ConditionalNavigation = memo(() => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
if (isCreateFlow) {
return null;
}
return <TopNavWithPathname />;
});
ConditionalNavigation.displayName = "ConditionalNavigation";
export default ConditionalNavigation;
export default async function ConditionalNavigation() {
const initialSignedIn = await getNavAuthSignedIn();
return <ConditionalNavigationClient initialSignedIn={initialSignedIn} />;
}
@@ -0,0 +1,31 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
export type ConditionalNavigationClientProps = {
initialSignedIn: boolean;
};
/**
* Client shell: pathname-based visibility. Session for the first paint comes from the
* parent Server Component (`ConditionalNavigation`) via `initialSignedIn`.
*/
const ConditionalNavigationClient = memo(
({ initialSignedIn }: ConditionalNavigationClientProps) => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow || isLogin) {
return null;
}
return <TopNavWithPathname initialSignedIn={initialSignedIn} />;
},
);
ConditionalNavigationClient.displayName = "ConditionalNavigationClient";
export default ConditionalNavigationClient;
@@ -139,14 +139,24 @@ const TopNavContainer = memo<TopNavProps>(
const isSmallBreakpoint = size === "xsmall" || size === "home";
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
const href = loggedIn ? "/profile" : "/login";
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
const ariaLabel = loggedIn
? t("ariaLabels.goToProfile")
: t("ariaLabels.logInToAccount");
const navSelected =
(loggedIn && pathname === "/profile") ||
(!loggedIn && pathname === "/login");
return (
<MenuBarItem
href="#"
href={href}
size={sizeMap[size] || "Small"}
mode={mode}
ariaLabel={t("ariaLabels.logInToAccount")}
state={navSelected ? "selected" : "default"}
ariaLabel={ariaLabel}
>
{t("buttons.logIn")}
{label}
</MenuBarItem>
);
};
@@ -1,19 +1,75 @@
"use client";
import { memo } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import TopNav from "./TopNav.container";
import type { TopNavProps } from "./TopNav.types";
import { fetchAuthSession } from "../../../../lib/create/api";
export type TopNavWithPathnameProps = Omit<TopNavProps, "folderTop"> & {
/** From Server Component (`getNavAuthSignedIn`); matches first HTML paint. */
initialSignedIn?: boolean;
};
/**
* TopNav wrapper that automatically determines folderTop based on current pathname.
* Use this in layout.tsx instead of ConditionalHeader.
* TopNav wrapper: `folderTop` from pathname; Log in vs Profile from session.
*
* **SSR:** Parent passes `initialSignedIn` from `getSessionUser()` so the hydrated
* header matches the cookie (Next.js pattern for HttpOnly session UI).
*
* **Client:** Refetch on pathname change (magic-link redirect, stale layout after
* `router.refresh()`), **popstate** / **pageshow** `persisted` (bfcache / back).
*/
const TopNavWithPathname = memo<Omit<TopNavProps, "folderTop">>((props) => {
const TopNavWithPathname = memo<TopNavWithPathnameProps>((props) => {
const { initialSignedIn = false, ...topNavRest } = props;
const pathname = usePathname();
const isHomePage = pathname === "/";
const [loggedIn, setLoggedIn] = useState(initialSignedIn);
return <TopNav {...props} folderTop={isHomePage} />;
useEffect(() => {
setLoggedIn(initialSignedIn);
}, [initialSignedIn]);
const applySessionUser = useCallback(
(user: { id: string; email: string } | null) => {
setLoggedIn(Boolean(user));
},
[],
);
const syncSession = useCallback(() => {
fetchAuthSession().then(({ user }) => {
applySessionUser(user);
});
}, [applySessionUser]);
useEffect(() => {
let cancelled = false;
fetchAuthSession().then(({ user }) => {
if (!cancelled) applySessionUser(user);
});
return () => {
cancelled = true;
};
}, [pathname, applySessionUser]);
useEffect(() => {
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) syncSession();
};
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, [syncSession]);
useEffect(() => {
const onPopState = () => {
queueMicrotask(syncSession);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [syncSession]);
return <TopNav {...topNavRest} folderTop={isHomePage} loggedIn={loggedIn} />;
});
TopNavWithPathname.displayName = "TopNavWithPathname";