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
@@ -4,13 +4,17 @@ import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashOtpCode } from "../../../../../lib/server/hash";
import { sendOtpEmail } from "../../../../../lib/server/mail";
import {
hashSessionToken,
newSessionToken,
} from "../../../../../lib/server/hash";
import { sendMagicLinkEmail } from "../../../../../lib/server/mail";
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
import { dbUnavailable } from "../../../../../lib/server/responses";
import { logger } from "../../../../../lib/logger";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
const OTP_TTL_MS = 10 * 60 * 1000;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
const IP_MIN_INTERVAL_MS = 20 * 1000;
@@ -21,6 +25,13 @@ function normalizeEmail(raw: unknown): string | null {
return email;
}
function readNextPath(body: unknown): string | null {
if (!body || typeof body !== "object" || !("next" in body)) return null;
const n = (body as { next: unknown }).next;
if (typeof n !== "string") return null;
return safeInternalPath(n);
}
export async function POST(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
@@ -39,7 +50,10 @@ export async function POST(request: NextRequest) {
: null,
);
if (!email) {
return NextResponse.json({ error: "Valid email required" }, { status: 400 });
return NextResponse.json(
{ error: "Valid email required" },
{ status: 400 },
);
}
const ip =
@@ -47,7 +61,7 @@ export async function POST(request: NextRequest) {
request.headers.get("x-real-ip") ??
"unknown";
const rlEmail = rateLimitKey(`otp-email:${email}`, EMAIL_MIN_INTERVAL_MS);
const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS);
if (rlEmail.ok === false) {
return NextResponse.json(
{ error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs },
@@ -55,7 +69,7 @@ export async function POST(request: NextRequest) {
);
}
const rlIp = rateLimitKey(`otp-ip:${ip}`, IP_MIN_INTERVAL_MS);
const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS);
if (rlIp.ok === false) {
return NextResponse.json(
{ error: "Too many requests", retryAfterMs: rlIp.retryAfterMs },
@@ -73,20 +87,29 @@ export async function POST(request: NextRequest) {
);
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const codeHash = hashOtpCode(code, pepper);
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
const token = newSessionToken();
const tokenHash = hashSessionToken(token, pepper);
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
const nextPath = readNextPath(body);
await prisma.otpChallenge.deleteMany({ where: { email } });
await prisma.otpChallenge.create({
data: { email, codeHash, expiresAt },
await prisma.magicLinkToken.deleteMany({ where: { email } });
await prisma.magicLinkToken.create({
data: {
email,
tokenHash,
expiresAt,
nextPath: nextPath ?? undefined,
},
});
const origin = request.nextUrl.origin;
const verifyUrl = `${origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
try {
await sendOtpEmail(email, code);
await sendMagicLinkEmail(email, verifyUrl);
} catch (err) {
logger.error("sendOtpEmail failed:", err);
await prisma.otpChallenge.deleteMany({ where: { email } });
logger.error("sendMagicLinkEmail failed:", err);
await prisma.magicLinkToken.deleteMany({ where: { email } });
return NextResponse.json(
{ error: "Could not send email" },
{ status: 502 },
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashSessionToken } from "../../../../../lib/server/hash";
import {
createSessionForUser,
setSessionCookie,
} from "../../../../../lib/server/session";
import { dbUnavailable } from "../../../../../lib/server/responses";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
export async function GET(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const token = request.nextUrl.searchParams.get("token");
if (!token || token.length < 10) {
return NextResponse.redirect(
new URL("/login?error=invalid_link", request.url),
);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.redirect(new URL("/login?error=server", request.url));
}
const tokenHash = hashSessionToken(token, pepper);
const row = await prisma.magicLinkToken.findUnique({
where: { tokenHash },
});
if (!row || row.expiresAt < new Date()) {
return NextResponse.redirect(
new URL("/login?error=expired_link", request.url),
);
}
await prisma.magicLinkToken.delete({ where: { id: row.id } });
const user = await prisma.user.upsert({
where: { email: row.email },
create: { email: row.email },
update: {},
});
const { token: sessionToken, expiresAt } = await createSessionForUser(
user.id,
);
await setSessionCookie(sessionToken, expiresAt);
const dest = safeInternalPath(row.nextPath);
return NextResponse.redirect(new URL(dest, request.url));
}
-108
View File
@@ -1,108 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashOtpCode } from "../../../../../lib/server/hash";
import {
createSessionForUser,
setSessionCookie,
} from "../../../../../lib/server/session";
import { dbUnavailable } from "../../../../../lib/server/responses";
const MAX_ATTEMPTS = 5;
function normalizeEmail(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const email = raw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
return email;
}
export async function POST(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const { email: rawEmail, code: rawCode } = body as {
email?: unknown;
code?: unknown;
};
const email = normalizeEmail(rawEmail);
const code =
typeof rawCode === "string"
? rawCode.replace(/\s/g, "")
: String(rawCode ?? "");
if (!email || !/^\d{6}$/.test(code)) {
return NextResponse.json(
{ error: "Valid email and 6-digit code required" },
{ status: 400 },
);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
const challenge = await prisma.otpChallenge.findFirst({
where: {
email,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
if (!challenge) {
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
}
if (challenge.attempts >= MAX_ATTEMPTS) {
await prisma.otpChallenge.delete({ where: { id: challenge.id } });
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
}
const expectedHash = hashOtpCode(code, pepper);
if (expectedHash !== challenge.codeHash) {
await prisma.otpChallenge.update({
where: { id: challenge.id },
data: { attempts: { increment: 1 } },
});
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
}
await prisma.otpChallenge.deleteMany({ where: { email } });
const user = await prisma.user.upsert({
where: { email },
create: { email },
update: {},
});
const { token, expiresAt } = await createSessionForUser(user.id);
await setSessionCookie(token, expiresAt);
return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email },
});
}
+3 -1
View File
@@ -23,7 +23,9 @@ export async function GET() {
});
return NextResponse.json({
draft: draft ? { payload: draft.payload, updatedAt: draft.updatedAt } : null,
draft: draft
? { payload: draft.payload, updatedAt: draft.updatedAt }
: null,
});
}
+1 -4
View File
@@ -14,9 +14,6 @@ export async function GET() {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true, database: "connected" });
} catch {
return NextResponse.json(
{ ok: false, database: "error" },
{ status: 503 },
);
return NextResponse.json({ ok: false, database: "error" }, { status: 503 });
}
}
@@ -0,0 +1,138 @@
"use client";
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
import { LoginView } from "./Login.view";
import type { LoginProps } from "./Login.types";
const LoginContainer = memo<LoginProps>(
({
isOpen,
onClose,
children,
belowCard,
className = "",
ariaLabel,
ariaLabelledBy,
usePortal = true,
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
const [portalReady, setPortalReady] = useState(() => !usePortal);
// Defer enabling the portal until after the layout commit so we dont sync-setState
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
// before the next paint, avoiding a flash of underlying layout.
useLayoutEffect(() => {
if (!usePortal) return;
const id = requestAnimationFrame(() => {
setPortalReady(true);
});
return () => cancelAnimationFrame(id);
}, [usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, portalReady, onClose, usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
if (dialogRef.current) {
const dialog = dialogRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusInitial = () => {
const emailField = dialog.querySelector<HTMLInputElement>(
'input[type="email"]:not([disabled])',
);
if (emailField) {
emailField.focus();
return;
}
const focusableElements = dialog.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
dialog.setAttribute("tabindex", "-1");
dialog.focus();
}
};
requestAnimationFrame(() => {
requestAnimationFrame(focusInitial);
});
}
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus();
};
}, [isOpen, portalReady, usePortal]);
return (
<LoginView
isOpen={isOpen}
onClose={onClose}
belowCard={belowCard}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
dialogRef={dialogRef}
backdropRef={backdropRef}
portalReady={portalReady}
usePortal={usePortal}
>
{children}
</LoginView>
);
},
);
LoginContainer.displayName = "Login";
export default LoginContainer;
@@ -0,0 +1,31 @@
export interface LoginProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
/** Rendered below the dialog card (e.g. “Back to home”) on the dimmed backdrop */
belowCard?: React.ReactNode;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* When false, render the overlay in the React tree instead of `document.body`.
* Use on the dedicated `/login` page so the shell (and heading) mount on first paint
* without waiting for a portal gate (more reliable across engines).
*/
usePortal?: boolean;
}
export interface LoginViewProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
belowCard?: React.ReactNode;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
dialogRef: React.RefObject<HTMLDivElement | null>;
backdropRef: React.RefObject<HTMLDivElement | null>;
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
portalReady: boolean;
usePortal: boolean;
}
@@ -0,0 +1,57 @@
"use client";
import { createPortal } from "react-dom";
import ModalHeader from "../../utility/ModalHeader";
import type { LoginViewProps } from "./Login.types";
export function LoginView({
isOpen,
onClose,
children,
belowCard,
className,
ariaLabel,
ariaLabelledBy,
dialogRef,
backdropRef,
portalReady,
usePortal,
}: LoginViewProps) {
if (!isOpen) return null;
if (usePortal && !portalReady) return null;
const content = (
<div
ref={backdropRef}
className="fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto bg-[var(--color-surface-inverse-brand-primary)] px-4 py-8"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
onClick={(e) => e.stopPropagation()}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
{children}
</div>
</div>
{belowCard ? (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
{belowCard}
</div>
) : null}
</div>
);
if (usePortal) {
return createPortal(content, document.body);
}
return content;
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { useCallback, useId, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button";
import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
import { requestMagicLink } from "../../../../lib/create/api";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
function MailIconInline() {
return (
<svg
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
data-name="Asset / Icon / mail"
>
<path
fill="#000000"
d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z"
/>
<path
fill="#000000"
d="M22.5 6.908V6.75A2.25 2.25 0 0 0 20.25 4.5h-16.5A2.25 2.25 0 0 0 1.5 6.75v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z"
/>
</svg>
);
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function LoginForm() {
const t = useTranslation("pages.login");
const tFooter = useTranslation("footer");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const formAlertId = useId();
const emailErrorId = useId();
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [emailError, setEmailError] = useState("");
const [formError, setFormError] = useState("");
const [sent, setSent] = useState(false);
const nextParam = searchParams.get("next");
const errorParam = searchParams.get("error");
/** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */
const stripErrorQuery = useCallback(() => {
if (!searchParams.get("error")) return;
const params = new URLSearchParams(searchParams.toString());
params.delete("error");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);
const sendLink = useCallback(async () => {
stripErrorQuery();
setEmailError("");
setFormError("");
const trimmed = email.trim().toLowerCase();
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("errors.emailInvalid"));
return;
}
setSubmitting(true);
try {
const nextPath = safeInternalPath(nextParam);
const result = await requestMagicLink(trimmed, nextPath);
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
setFormError(
t("errors.rateLimited").replace("{seconds}", String(seconds)),
);
} else {
setFormError(result.error || t("errors.generic"));
}
return;
}
setEmail(trimmed);
setSent(true);
} catch {
setFormError(t("errors.network"));
} finally {
setSubmitting(false);
}
}, [email, nextParam, stripErrorQuery, t]);
const urlErrorMessage =
errorParam === "expired_link"
? t("errors.expiredLink")
: errorParam === "invalid_link" || errorParam === "server"
? errorParam === "server"
? t("errors.serverError")
: t("errors.invalidLink")
: "";
return (
<div className="flex flex-col gap-6 pt-2">
<div className="flex flex-col gap-3">
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
<MailIconInline />
</div>
<ContentLockup
titleId="login-modal-heading"
title={sent ? t("successTitle") : t("title")}
description={sent ? t("successBody") : t("subtitle")}
variant="login"
alignment="left"
/>
</div>
{urlErrorMessage ? (
<p
role="alert"
aria-live="polite"
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{urlErrorMessage}
</p>
) : null}
{formError ? (
<p
id={formAlertId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{formError}
</p>
) : null}
{!sent ? (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
void sendLink();
}}
noValidate
>
<TextInput
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
stripErrorQuery();
}}
disabled={submitting}
error={Boolean(emailError)}
showHelpIcon
/>
{emailError ? (
<p
id={emailErrorId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{emailError}
</p>
) : null}
<Button
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
>
{t("sendMagicLink")}
</Button>
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.termsOfService")}
</Link>
{t("legalAnd")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.privacyPolicy")}
</Link>
{t("legalSuffix")}
</p>
</form>
) : null}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Login.container";
export type { LoginProps } from "./Login.types";
@@ -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";
@@ -112,6 +112,20 @@ const ContentLockupContainer = memo<ContentLockupProps>(
"font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
shape: "w-[16px] h-[16px]",
},
login: {
container:
"flex flex-col gap-[var(--spacing-scale-012)] items-start justify-center relative w-full",
textContainer: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
titleContainer: "flex items-center justify-start w-full",
title:
"font-bricolage-grotesque font-extrabold text-[36px] leading-[44px] tracking-[0] text-[var(--color-content-default-primary)] text-left",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
description:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
shape: "w-[16px] h-[16px]",
},
};
const styles = variantStyles[variant] || variantStyles.hero;
@@ -5,12 +5,14 @@ export type ContentLockupVariantValue =
| "ask"
| "ask-inverse"
| "modal"
| "login"
| "Hero"
| "Feature"
| "Learn"
| "Ask"
| "Ask-Inverse"
| "Modal";
| "Modal"
| "Login";
export type ContentLockupAlignmentValue = "center" | "left" | "Center" | "Left";
@@ -58,7 +60,14 @@ export interface ContentLockupViewProps {
ctaText?: string;
ctaHref?: string;
buttonClassName: string;
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal";
variant:
| "hero"
| "feature"
| "learn"
| "ask"
| "ask-inverse"
| "modal"
| "login";
linkText?: string;
linkHref?: string;
alignment: "center" | "left";
@@ -20,18 +20,21 @@ function ContentLockupView({
}: ContentLockupViewProps) {
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" || variant === "modal" ? (
/* Simplified structure for ask and modal variants */
{variant === "ask" ||
variant === "ask-inverse" ||
variant === "modal" ||
variant === "login" ? (
/* Simplified structure for ask, modal, and login variants */
<div
className={`${styles.titleGroup} ${
alignment === "left" || variant === "modal"
alignment === "left" || variant === "modal" || variant === "login"
? "text-left"
: "text-center"
}`}
>
<div
className={`${styles.titleContainer} ${
alignment === "left" || variant === "modal"
alignment === "left" || variant === "modal" || variant === "login"
? "justify-start"
: "justify-center"
}`}
@@ -43,7 +46,7 @@ function ContentLockupView({
) : null}
</div>
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
{variant === "modal" && description && (
{(variant === "modal" || variant === "login") && description && (
<p className={styles.description}>{description}</p>
)}
</div>
@@ -1,6 +1,9 @@
import { getAssetPath } from "../../../../lib/assetUtils";
import type { ModalHeaderProps } from "./ModalHeader.types";
const iconButtonClass =
"absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
export function ModalHeaderView({
onClose,
onMoreOptions,
@@ -15,8 +18,9 @@ export function ModalHeaderView({
{/* Close Button - Left */}
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
className={`${iconButtonClass} left-[24px] top-[12px]`}
aria-label="Close dialog"
>
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
@@ -34,8 +38,9 @@ export function ModalHeaderView({
{/* More Options Button - Right */}
{showMoreOptionsButton && (
<button
type="button"
onClick={onMoreOptions}
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full right-[24px] top-[12px] flex items-center justify-center cursor-pointer"
className={`${iconButtonClass} right-[24px] top-[12px]`}
aria-label="More options"
>
<svg
+1 -2
View File
@@ -8,8 +8,7 @@ import {
} from "../../../lib/create/api";
import { useCreateFlow } from "./CreateFlowContext";
const SYNC_ENABLED =
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
const DEBOUNCE_MS = 1000;
+3
View File
@@ -7,6 +7,9 @@ import "./globals.css";
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
import ConditionalFooter from "./components/navigation/ConditionalFooter";
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
export const dynamic = "force-dynamic";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslation } from "../contexts/MessagesContext";
import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm";
export default function LoginPageClient() {
const router = useRouter();
const t = useTranslation("pages.login");
return (
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
<Login
isOpen
usePortal={false}
onClose={() => {
router.push("/");
}}
ariaLabelledBy="login-modal-heading"
belowCard={
<Link
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
{t("backToHome")}
</Link>
}
>
<LoginForm />
</Login>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import LoginPageClient from "./LoginPageClient";
export const metadata: Metadata = {
title: "Log in · CommunityRule",
robots: { index: false, follow: false },
};
function LoginFallback() {
return (
<div className="min-h-screen bg-[var(--color-surface-inverse-brand-primary)] flex items-center justify-center">
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
Loading
</p>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginFallback />}>
<LoginPageClient />
</Suspense>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useTranslation } from "../contexts/MessagesContext";
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
return (
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
<h1 className="font-bricolage text-3xl font-extrabold text-[var(--color-content-default-primary)] md:text-4xl">
{t("placeholderTitle")}
</h1>
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
{t("placeholderBody")}
</p>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = {
title: "Profile · CommunityRule",
robots: { index: false, follow: false },
};
export default function ProfilePage() {
return <ProfilePageClient />;
}