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
+2 -2
View File
@@ -3,11 +3,11 @@
# PostgreSQL — use `docker compose up -d postgres` and match user/db/password.
DATABASE_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
# Session signing + OTP pepper (min 16 characters; use a long random string in production).
# Session signing + secret used when hashing magic-link tokens. Min 16 characters; use a long random string in production.
SESSION_SECRET="dev-only-change-me-16chars-min"
# Optional: Nodemailer transport URL, e.g. `smtp://localhost:1025` with Mailhog from docker-compose.
# Leave unset in development to log OTP codes to the server console instead.
# Leave unset in development to log the magic-link verify URL to the server console instead of sending email.
SMTP_URL=
SMTP_FROM="Community Rule <noreply@localhost>"
+20 -11
View File
@@ -3,7 +3,7 @@
## Backend (local)
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, OTPs are printed in the dev server log (see `.env.example`).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
3. Install dependencies: `npm ci`
4. Apply migrations: `npx prisma migrate dev`
5. Run the app: `npm run dev`
@@ -17,16 +17,25 @@ Use `npx prisma studio` to inspect the database.
### API routes (overview)
| Method | Path | Purpose |
| ---------- | ----------------------- | --------------------------------------------- |
| GET | `/api/health` | Liveness / DB check |
| GET | `/api/auth/session` | Current user or null |
| POST | `/api/auth/otp/request` | Send email OTP |
| POST | `/api/auth/otp/verify` | Verify OTP, set session cookie |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules |
| GET | `/api/templates` | List curated templates |
| Method | Path | Purpose |
| ---------- | ------------------------------ | --------------------------------------------- |
| GET | `/api/health` | Liveness / DB check |
| GET | `/api/auth/session` | Current user or null |
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules |
| GET | `/api/templates` | List curated templates |
### Email magic link (sign-in)
- Open **[http://localhost:3000/login](http://localhost:3000/login)** or use **Log in** in the site header (modal or full page).
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
- **No `SMTP_URL`:** the full **`GET /api/auth/magic-link/verify?...`** URL is printed in the **dev server terminal** — paste it into the browser address bar.
- **Mailhog:** with Compose Mailhog running, set `SMTP_URL=smtp://localhost:1025` and open the link from the message in the Mailhog UI ([http://localhost:8025](http://localhost:8025)).
**Staging / production:** Sign-in links use the apps origin. Ensure your reverse proxy sets **`Host`** (and TLS) so links in email match the URL users open. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §9.
### Optional draft sync
@@ -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 />;
}
+122 -56
View File
@@ -6,7 +6,7 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**.
### Review sync (relevant feedback only)
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), in-memory OTP limits until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**. **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors, blocked by CR-73), **CR-85** (session lifecycle, blocked by CR-75)—see **Linear** table at the end of this doc.
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors, blocked by CR-73), **CR-85** (session lifecycle, blocked by CR-75)—see **Linear** table at the end of this doc.
---
@@ -16,7 +16,7 @@ Use this if you **do not** have SSH or hosting access yet. Most engineering tick
### You do **not** need the server admin for
- **Tickets 18, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. OTP email can use Mailhog or dev log (no real SMTP).
- **Tickets 18, 10:** Everything runs on your machine: `docker compose up -d postgres mailhog`, `.env`, `npm run dev`, `npx prisma migrate dev`. **Magic-link** sign-in email can use Mailhog or **dev server logs** (verify URL) when `SMTP_URL` is unset—no real SMTP required locally.
- **Verifying APIs:** Use `localhost` and the same Docker Postgres—no production host.
### The **first** time you need someone with hosting access
@@ -29,9 +29,9 @@ Ask the admin to provide (or do for you) the items below—**Ticket 12** turns t
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Postgres** | Managed instance or container; a **`DATABASE_URL`** you can plug into the deployed app. |
| **Run migrations** | Someone runs **`npx prisma migrate deploy`** against that database **before** the new app version serves traffic (or gives you a secure way to run it in CI/CD). |
| **`SESSION_SECRET`** | Long random string in production env (sessions + OTP hashing). |
| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real OTP email; not required on laptop if you use logs/Mailhog. |
| **DNS for mail** | Often **SPF/DKIM** so OTP messages are not spam—admin or whoever owns DNS. |
| **`SESSION_SECRET`** | Long random string in production env (sessions **+ hashed magic-link tokens**). |
| **SMTP** | **`SMTP_URL`** + **`SMTP_FROM`** for real **sign-in link** email; not required on laptop if you use logs/Mailhog. |
| **DNS for mail** | Often **SPF/DKIM** so **magic-link** messages are not spam—admin or whoever owns DNS. |
| **TLS + hostname** | HTTPS URL for the site; reverse proxy (nginx, Caddy, etc.) in front of Node. |
| **Health check** | Load balancer or platform should probe **`GET /api/health`** (or your chosen path). |
| **Secrets storage** | Env vars or secret manager—never commit `.env` with secrets. |
@@ -44,7 +44,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
| Ticket | Need server admin? | What for |
| ------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 12 | **No** | Docs and app code only. |
| 3 | **No** to build/test; **Yes** when OTP must work on a **deployed** env | Real **SMTP** + DNS on staging/prod (same as table above). |
| 3 | **No** to build/test; **Yes** when **magic-link email** must work on a **deployed** env | Real **SMTP** + DNS on staging/prod (same as table above). |
| 48 | **No** | Local or staging URL is still “your” deploy—admin only if that URL is on their infra. |
| 9 | **No** to implement; **Yes** when **production** uses multiple instances or read-only FS | **Default** is external RUM/log drain; Postgres vitals only if ops explicitly wants one datastore—may need vendor keys for SaaS. |
| 10 | **No** to code | Same deploy pipeline as the rest of the app. |
@@ -113,31 +113,44 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
---
## Ticket 3 — Email OTP sign-in UI (end-to-end with existing APIs)
## Ticket 3 — Email magic-link sign-in UI (end-to-end with existing APIs)
**Depends on:** Ticket 2 (soft dependency: types help name fields you might store post-login; can start in parallel if needed).
**Server / admin:** **Not required** to build and test (Mailhog or console OTP locally). **Required** when OTP must work on **staging/production**: admin provides **SMTP** + usually **DNS (SPF/DKIM)** and sets env on the host (see top table).
**Server / admin:** **Not required** to build and test (Mailhog or verify URL in server logs locally). **Required** when **magic-link email** must work on **staging/production**: admin provides **SMTP** + usually **DNS (SPF/DKIM)** and sets env on the host (see top table). **Residual:** links in email use the app origin—reverse proxy / `Host` must match the URL users open.
**Goal:** Let a user request a code and verify it in the browser using existing endpoints.
**Goal:** Let a user request a **sign-in link** and complete sign-in in the browser using existing endpoints.
**Context:** APIs exist: `POST /api/auth/otp/request`, `POST /api/auth/otp/verify`, `GET /api/auth/session`, `POST /api/auth/logout`. Client helpers: [lib/create/api.ts](lib/create/api.ts).
**Context:** APIs: `POST /api/auth/magic-link/request`, `GET /api/auth/magic-link/verify`, `GET /api/auth/session`, `POST /api/auth/logout`. Prisma: `MagicLinkToken`. Client: [`requestMagicLink`](lib/create/api.ts).
**Implementation:**
**Implementation (shipped):**
1. Add a **route** (e.g. `app/(marketing)/login/page.tsx`) or a **modal** from the main header, designer-approved.
2. Flow: email → “Send code” → 6-digit code → “Verify” → success closes UI or redirects to `/create` (product decision).
3. Surface API errors: invalid email, 429 `retryAfterMs`, wrong code, network failure (accessible copy).
4. Ensure `fetch` calls use `credentials: "include"` (already in `lib/create/api.ts`).
5. **Dev:** document that without `SMTP_URL`, OTP prints to server logs; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
1. **`/login`** route and/or **modal** from the header (designer-approved)—[app/login/page.tsx](app/login/page.tsx), [app/login/LoginPageClient.tsx](app/login/LoginPageClient.tsx), [app/components/modals/Login/](app/components/modals/Login/) (`LoginForm.tsx`, container/view).
2. Flow: email → “Send link” → user opens link (email, Mailhog, or dev log) → `GET /api/auth/magic-link/verify?token=...` sets session and redirects; optional `next` for post-login path.
3. Surface API errors: invalid email, 429 `retryAfterMs`, expired/invalid token, network failure (accessible copy).
4. Ensure `fetch` calls use `credentials: "include"` where needed (see [lib/create/api.ts](lib/create/api.ts)).
5. **Dev:** without `SMTP_URL`, verify URL is logged; with Mailhog, use [docker-compose.yml](docker-compose.yml) and `SMTP_URL=smtp://localhost:1025`.
6. **Marketing header:** When signed in (`fetchAuthSession`), **Log in** becomes **Profile** linking to [`/profile`](app/profile/page.tsx) (placeholder until Ticket 15 / CR-86). Implemented in [TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx) + [TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx).
**Acceptance criteria:**
- [ ] Happy path: user can complete OTP and `GET /api/auth/session` returns user in the same browser session.
- [ ] Keyboard + screen-reader friendly forms (labels, errors associated with fields).
- [ ] No secrets in client bundle.
- [x] Happy path: user completes magic-link verify and `GET /api/auth/session` returns `user` in the same browser session.
- [x] Keyboard + screen-reader friendly forms (labels, errors associated with fields).
- [x] No secrets in client bundle.
- [x] Header shows **Profile** → placeholder `/profile` when session present; **Log in** when anonymous.
**Files:** new page/components under `app/` and `app/components/…`, optional [messages/en/…](messages/en/) JSON for i18n, [lib/create/api.ts](lib/create/api.ts) only if you need new helpers.
**Status:** [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) **Done** for shipped UI/APIs. **Residual checklist** below: repo doc items are **done**; use Linear (CR-74 or child issue) to track **per-environment** staging URL checks.
**Files:** [app/login/](app/login/), [app/profile/](app/profile/) (placeholder), [app/components/modals/Login/](app/components/modals/Login/), [messages/en/pages/login.json](messages/en/pages/login.json), [messages/en/pages/profile.json](messages/en/pages/profile.json), [messages/en/components/header.json](messages/en/components/header.json), [app/components/navigation/TopNav/TopNav.container.tsx](app/components/navigation/TopNav/TopNav.container.tsx), [app/components/navigation/TopNav/TopNavWithPathname.tsx](app/components/navigation/TopNav/TopNavWithPathname.tsx), [lib/create/api.ts](lib/create/api.ts), [app/api/auth/magic-link/request/route.ts](app/api/auth/magic-link/request/route.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), [prisma/schema.prisma](prisma/schema.prisma) (`MagicLinkToken`), [lib/server/mail.ts](lib/server/mail.ts). Onboarding: [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example).
### Residual / before CR-75 (create-flow session UI)
**Intent:** [Ticket 4](#ticket-4--session-affordances-in-the-create-flow-signed-in-state--sign-out) (**CR-75**) needs a reliable signed-in story across marketing + `/create`. Below: what is **done in repo** vs what to **verify per environment**.
1. **Contributor / onboarding****Done:** [CONTRIBUTING.md](CONTRIBUTING.md) API table and sign-in section describe **magic-link** request/verify, dev log URL, and Mailhog. [`.env.example`](.env.example) comments match.
2. **Smoke checklist****Done:** **Email magic link (sign-in)** in [CONTRIBUTING.md](CONTRIBUTING.md); build-order §9 in [docs/backend-roadmap.md](backend-roadmap.md) includes the same happy path + session check.
3. **Staging / production URLs****Verify on each deploy:** emails use `request.nextUrl.origin`; confirm reverse proxy and **`Host`** so links in mail match the public site (CONTRIBUTING + roadmap §9 spell this out).
4. **Docs alignment****Done:** [docs/backend-roadmap.md](backend-roadmap.md) and this doc treat magic link as primary; CR-72/CR-73 schema work is not a blocker for CR-75.
---
@@ -290,6 +303,8 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Goal:** Shareable link for a published rule.
**Note:** Complements **Ticket 15** profile cards: users can open a **public** detail URL from a rule listed on their dashboard; the profile page does **not** replace this ticket.
**Implementation:**
1. Add `GET /api/rules/[id]/route.ts` returning `{ rule }` or 404 (public read; no secrets).
@@ -303,6 +318,8 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Files:** new route handler, new page, optional layout.
**Linear:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional). **Related in Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (Ticket 15 — profile cards linking to public detail).
---
## Ticket 11 — CI: database migration smoke (optional, runner-dependent)
@@ -389,7 +406,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Implementation:**
1. **Policy:** On new OTP login, decide whether to **delete other `Session` rows** for that user (single active session) or allow multiple devices (document choice).
1. **Policy:** On **new sign-in** (magic-link verification / session creation), decide whether to **delete other `Session` rows** for that user (single active session) or allow multiple devices (document choice).
2. **Rotation (optional v1.1):** Issue new token on privilege-sensitive actions if product requires.
3. **Cleanup:** Delete or mark expired sessions (scheduled job, or prune on read with occasional batch).
4. **Docs:** Add short ADR or comment block in `lib/server/session.ts`.
@@ -399,52 +416,101 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
- [ ] Documented behavior matches implementation.
- [ ] Expired sessions do not accumulate unbounded in production over months.
**Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/otp/verify/route.ts](app/api/auth/otp/verify/route.ts) if invalidating siblings, optional `prisma` migration if new columns (unlikely).
**Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), optional `prisma` migration if new columns (unlikely).
**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (blocked by **CR-75**).
---
## Ticket 15 — Profile dashboard + account (Figma profile)
**Depends on:** Ticket 3 (auth), **Ticket 4** (session in UI), **Ticket 6** (publish so users have rules to list). Soft optional: Tickets 78 for “create from template” CTA parity.
**Goal:** Signed-in **profile** experience matching [Figma — Profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069): **Your CommunityRules** (list **own** published rules), **duplicate** / **delete** per rule, CTAs into create flow (custom + from template), **logout** (existing API), **delete account** (policy + API + confirmation UX).
**Out of scope for this ticket**
- **Change your account email** (shown in Figma options): **deferred**—no backend in this slice. Product may **hide** the row, show **“Coming soon,”** or backlog until a **future ticket** (verified email change, conflicts, sessions).
- **`displayName` / new `User` fields:** not required—use **static** welcome copy, generic greeting, or **email local-part in UI only** until a later schema/product decision.
**Context:** Today `GET /api/rules` is a **public** list of all published rules; there is no authenticated **my rules** endpoint, no owner **DELETE** / **duplicate**, and no **delete user** API. See [docs/backend-roadmap.md](backend-roadmap.md) §1 “profile / account — not implemented yet” and §6.
**Implementation (sketch):**
1. **API:** Authenticated route(s) to list rules **where `userId` = session user**; owner-only `DELETE` (and duplicate via `POST` reuse or dedicated handler); `DELETE` user (or equivalent) with explicit Prisma policy—cascade vs orphan `PublishedRule` (today `onDelete: SetNull` on user) and cleanup of `Session` / `RuleDraft`.
2. **UI:** Marketing route (e.g. `/profile`), rule cards (title, summary, artwork from `document` as needed), **IN PROGRESS** badge per roadmap §4 (derive from JSON / future `status` / UI-only).
3. **Nav:** Link from header when signed in if design requires.
4. **i18n** for strings; legal/product review for **delete account** copy.
**Acceptance criteria:**
- [ ] Signed-in user sees **only their** published rules (not the global public list).
- [ ] Duplicate and delete actions work for **owner** only; errors are clear.
- [ ] Logout still works from profile context.
- [ ] Delete account flow matches agreed policy and is confirmed in UI.
- [ ] No verified **email change** shipped in this ticket; Figma row handled per product (hide/disabled/backlog).
**Files:** new `app/` routes and components, `app/api/rules/...` (or new segment handlers), [lib/create/api.ts](lib/create/api.ts) as needed, [prisma/schema.prisma](prisma/schema.prisma) only if account-delete policy requires schema tweaks, [messages/en/](messages/en/) for copy.
**Linear:** [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) (**Backlog**). **Blocked by** **CR-75** + **CR-77**. **Related:** [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) (public rule detail for deep links from profile cards). **Not** part of the sequential **CR-72 → CR-83** chain—parallel after publish + session, similar to CR-84/CR-85.
---
## Summary order
| Order | Ticket | Short name |
| ----: | ------ | -------------------------------- |
| 1 | 1 | Refresh backend-roadmap |
| 2 | 2 | CreateFlowState + API validation |
| 3 | 3 | OTP sign-in UI |
| 4 | 4 | Create flow session UI |
| 5 | 5 | Draft sync hardening |
| 6 | 6 | Publish wiring |
| 7 | 7 | Template seed |
| 8 | 8 | Templates in UI |
| 9 | 9 | Web vitals persistence |
| 10 | 10 | Public rule detail (optional) |
| 11 | 11 | CI migrate smoke (optional) |
| 12 | 12 | Ops runbook |
| 13 | 13 | API errors + request-id logging |
| 14 | 14 | Session lifecycle + cleanup |
| Order | Ticket | Short name |
| ----: | ------ | --------------------------------- |
| 1 | 1 | Refresh backend-roadmap |
| 2 | 2 | CreateFlowState + API validation |
| 3 | 3 | Magic-link sign-in UI |
| 4 | 4 | Create flow session UI |
| 5 | 5 | Draft sync hardening |
| 6 | 6 | Publish wiring |
| 7 | 7 | Template seed |
| 8 | 8 | Templates in UI |
| 9 | 9 | Web vitals persistence |
| 10 | 10 | Public rule detail (optional) |
| 11 | 11 | CI migrate smoke (optional) |
| 12 | 12 | Ops runbook |
| 13 | 13 | API errors + request-id logging |
| 14 | 14 | Session lifecycle + cleanup |
| 15 | 15 | Profile + account (Figma profile) |
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 1314** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83.
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 1314** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by auth + session + publish—not by the ops runbook); Linear: **CR-86**.
---
## Linear (Community-rule team)
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (blocked by CR-73), **CR-85** (blocked by CR-75).
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (blocked by CR-73), **CR-85** (blocked by CR-75), **CR-86** / Ticket 15 (blocked by CR-75 + CR-77, not in the CR-7283 sequence).
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) | OTP sign-in UI |
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out) | Create flow session UI |
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-createflowbackendsync) | Draft sync hardening |
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring |
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) |
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis) | Magic-link sign-in UI + CR-75 prep |
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out) | Create flow session UI |
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-createflowbackendsync) | Draft sync hardening |
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring |
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) |
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
---
## Updating Linear issue CR-74 (manual)
Keep **[CR-74](https://linear.app/community-rule/issue/CR-74/backend-email-otp-sign-in-ui-existing-apis)** aligned with **Ticket 3** (Linear UI or MCP). If Linear still describes an old sign-in approach, update it so it matches **Ticket 3** above (magic link only):
- **Title (examples):** `Magic-link sign-in UI + APIs; prep for CR-75` or `Email magic-link sign-in (UI + routes) — residuals for create-flow auth`.
- **Description — Shipped:** Magic link: `POST /api/auth/magic-link/request`, `GET /api/auth/magic-link/verify`, `MagicLinkToken`, `/login` + modal UI, `requestMagicLink`, session cookie.
- **Description — Residual / before CR-75:** Use the checklist under **Residual / before CR-75** (Ticket 3 above). Mark **done** for items 1, 2, and 4 (repo docs). Keep **open** until verified: **(3)** staging/prod `Host` / link URLs on your real hosts.
- **Comment (optional):** Start **CR-75** only after residuals are done **or** the team defers specific lines (e.g. CONTRIBUTING in a separate PR).
**Status:** CR-74 can stay **Done** with a **child issue** (e.g. “CR-74 follow-ups: auth docs + smoke”) if you prefer not to reopen the parent.
+54 -36
View File
@@ -7,7 +7,7 @@ Temporary working notes for building the backend. Safe to delete once the stack
## 1. Where we are
- **Next.js 16** single repo ([`package.json`](package.json)).
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/OTP, session, drafts, rules, templates, web-vitals).
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals).
- **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.).
- **Create flow** persists in the browser (`localStorage`); optional **server draft sync** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and the user is signed in ([`app/create/context/CreateFlowBackendSync.tsx`](app/create/context/CreateFlowBackendSync.tsx)).
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production).
@@ -17,19 +17,31 @@ Temporary working notes for building the backend. Safe to delete once the stack
Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table; handlers live under `app/api/*/route.ts`.
| Method | Path | Purpose |
| ---------- | ----------------------- | --------------------------------------------- |
| GET | `/api/health` | Liveness / DB check |
| GET | `/api/auth/session` | Current user or null |
| POST | `/api/auth/otp/request` | Send email OTP |
| POST | `/api/auth/otp/verify` | Verify OTP, set session cookie |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules |
| GET | `/api/templates` | List curated templates |
| Method | Path | Purpose |
| ---------- | ------------------------------ | --------------------------------------------- |
| GET | `/api/health` | Liveness / DB check |
| GET | `/api/auth/session` | Current user or null |
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules |
| GET | `/api/templates` | List curated templates |
**Product sign-in** uses **magic link** (`/api/auth/magic-link/*`).
**Also present (not in CONTRIBUTING table):** `POST` / `GET` [`/api/web-vitals`](../app/api/web-vitals/route.ts) — file-based store today; production path TBD (§7).
### HTTP API (profile / account — not implemented yet)
Planned for the signed-in profile/dashboard ([Figma profile frame](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069); [docs/backend-linear-tickets.md](backend-linear-tickets.md) Ticket 15; Linear **[CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile)**):
- Authenticated list of **own** `PublishedRule` rows (e.g. `GET /api/rules/me` or a strictly scoped query—**not** the same as public `GET /api/rules`).
- Owner-only **delete** and **duplicate** (clone) for published rules.
- **Delete account** (authenticated), with an explicit policy for drafts, sessions, and linked rules.
**Future (separate ticket):** **Change email** with verification (e.g. magic link to a new address, conflict handling)—**out of scope** for the profile milestone above.
---
## 2. What were building
@@ -41,7 +53,7 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table; handlers liv
- HTTP handlers under `app/api/…`
- Shared server code under `lib/server/…`
**Step 3.** Use the old backend only as a **product hint** (email OTP, saving rules, listing rules). Do **not** copy its Express layout or MySQL schema.
**Step 3.** Use the old backend only as a **product hint** (passwordless email sign-in, saving rules, listing rules). Do **not** copy its Express layout or MySQL schema.
---
@@ -51,9 +63,9 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table; handlers liv
**Step 2.** Use **Prisma**`schema.prisma`, `npx prisma migrate dev` / `migrate deploy`.
**Step 3.** Add **SMTP** (or Mailhog locally) for email OTP in deployed environments; dev can log OTP to the console when `SMTP_URL` is unset.
**Step 3.** Add **SMTP** (or Mailhog locally) for **magic-link** sign-in email in deployed environments; when `SMTP_URL` is unset in dev, the app can log the **verify URL** to the console (same pattern as [`lib/server/mail.ts`](lib/server/mail.ts)).
**Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for OTP endpoints; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)).
**Step 4.** **Redis / queues / Kubernetes** — not required for v1. **Exception:** before running **multiple app instances**, plan a **shared rate-limit store** (often Redis) for **passwordless email (magic-link request)**; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)).
---
@@ -61,14 +73,14 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table; handlers liv
Plain-English entities (names can evolve):
| Area | Purpose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **User** | Identified by email after OTP verification. |
| **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. |
| **OtpChallenge** | Short-lived email codes (hashed). |
| **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. |
| **PublishedRule** | Saved rule after publish (title, summary, document JSON). |
| **RuleTemplate** | Curated templates (slug, category, ordering). |
| Area | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **User** | Identified by email after **magic link verification** (primary v1 path). An optional **display name** (or preferred name) could be added later for richer greetings; it does **not** block the profile page—no schema commitment in this roadmap pass alone. |
| **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. |
| **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. |
| **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. |
| **PublishedRule** | Saved rule after publish (title, summary, document JSON). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. |
| **RuleTemplate** | Curated templates (slug, category, ordering). |
**Session follow-ups to implement or decide:** token **rotation** on sensitive events, whether **new login invalidates other sessions**, and **cleanup** of expired `Session` rows (job or lazy delete). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
@@ -80,8 +92,8 @@ Align JSON shapes with `app/create/types.ts` as it matures.
## 5. Session and authentication (v1)
- **Decision:** **Custom** database-backed sessions + email OTP; cookies are **httpOnly**; tokens are hashed at rest.
- **OTP rate limiting:** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure.
- **Decision:** **Custom** database-backed sessions + **email magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest.
- **Rate limiting (magic-link request):** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure.
- Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead.
---
@@ -92,8 +104,10 @@ Match the current API behavior; tighten as product evolves:
- **`GET /api/drafts/me` / `PUT /api/drafts/me`:** Authenticated user only; draft is **scoped to that user** (`userId`).
- **`POST /api/rules`:** Authenticated user only; rule is stored with **`userId`** (owner).
- **`GET /api/rules`:** **Public list** of published rules (metadata: id, title, summary, timestamps)—no auth required today. **Not** a private “my rules” feed unless you add a separate route later.
- **v1:** No **editing** or **deleting** published rules via API in the shipped handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
- **`GET /api/rules`:** **Public list** of published rules (metadata: id, title, summary, timestamps)—no auth required today. **Not** a private “my rules” feed unless you add a separate route later (see §1 “profile / account — not implemented yet” and Ticket 15).
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; plan a **future ticket** for verified email updates.
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
---
@@ -122,7 +136,7 @@ Match the current API behavior; tighten as product evolves:
**Backend behavior already in the repo:** Steps **510** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7.
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumptionsee §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption, **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
---
@@ -149,12 +163,14 @@ npm run dev
**Step 5.** Confirm **health**: `GET /api/health` should return JSON.
**Step 6.** **OTP login** (happy path):
**Step 6.** **Magic-link sign-in** (happy path):
1. `POST /api/auth/otp/request` with `{ "email": "you@example.com" }`
2. Read the code from your mail catcher or server logs (dev).
3. `POST /api/auth/otp/verify` with `{ "email": "...", "code": "..." }`
4. `GET /api/auth/session` should show your user.
1. `POST /api/auth/magic-link/request` with `{ "email": "you@example.com" }` (optional `"next"` for redirect after verify).
2. Open the link from email, Mailhog, or **server logs** when `SMTP_URL` is unset (dev).
3. Browser hits `GET /api/auth/magic-link/verify?token=...` (and optional `next=...`); response sets the session cookie and redirects.
4. `GET /api/auth/session` should show your user in the same browser.
**Before wiring create-flow session UI:** Confirm the same browser that completed verify gets `user` from `GET /api/auth/session` (cookie + same-site). On **staging/production**, magic-link emails embed the app origin—misconfigured **`Host`** or TLS termination can produce broken links; align reverse proxy with the public site URL.
**Step 7.** **Drafts**: With a session, `GET /api/drafts/me` and `PUT /api/drafts/me` with `{ "payload": { ... } }` (create flow state object).
@@ -171,8 +187,8 @@ npm run dev
## 10. Security checklist
- **HTTPS** in staging/production; session cookie **Secure**.
- **Rate-limit** OTP (in-memory OK for one instance; **shared store before multi-instance**see §5).
- **Hash** OTP codes and session tokens before storing; short OTP expiry.
- **Rate-limit** magic-link **request**in-memory OK for one instance; **shared store before multi-instance** (see §5).
- **Hash** magic-link tokens and session tokens before storing; short **magic-link** TTL (align with implementation, e.g. 15 minutes).
- **Secrets** only in env / secret store — never commit `.env` with real values.
- **CORS:** prefer **same-origin** `/api/*`; if cross-origin, configure CORS and CSRF carefully.
@@ -204,16 +220,18 @@ npm run dev
**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to opt in to server drafts when logged in.
**Step 3.** Implement sign-in UI when you are ready: call the OTP routes, then rely on the browser cookie for `/api/drafts/me`.
**Step 3.** Sign-in UI: **`/login`** (and **Log in** in the site header) uses **magic link** (modal / page flow: request link → open verify URL); after verify, rely on the browser cookie for `/api/drafts/me`.
**Step 4.** On publish, call `POST /api/rules` from the completed step when the backend is required (wire when the final review UI is ready).
**Step 5.** **Profile / dashboard** (`/profile` or agreed path): signed-in hub for **my rules** (after Ticket 15 APIs exist), **duplicate** / **delete** rule actions, **logout**, **delete account**—aligned with [Figma profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069). **Change email** in design is **deferred** (hide, “coming soon,” or backlog) until a future account ticket; greeting copy can stay **static** or use **email local-part in UI only**—no `displayName` field required for MVP.
---
## 13. Optional later
- **Session library** spike (Auth.js, Lucia) if custom lifecycle cost grows.
- **Redis** (or similar) for **shared OTP rate limits** and horizontal scale.
- **Redis** (or similar) for **shared magic-link rate limits** and horizontal scale.
- **RuleDraft** versioning or multiple drafts per user.
- Standalone **API service** (Fastify/Hono) if scaling or workers demand it.
- **OpenAPI** if external API clients appear.
+20 -32
View File
@@ -37,42 +37,31 @@ export async function fetchAuthSession(): Promise<{
return parseJson(res);
}
export async function requestOtp(email: string): Promise<{ ok: true } | { error: string }> {
const res = await fetch("/api/auth/otp/request", {
export async function requestMagicLink(
email: string,
nextPath?: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
const res = await fetch("/api/auth/magic-link/request", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email }),
body: JSON.stringify({
email,
...(nextPath ? { next: nextPath } : {}),
}),
});
const data = await parseJson<{ error?: string }>(res);
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
if (!res.ok) {
return { error: readApiErrorMessage(data) };
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs:
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
};
}
return { ok: true };
}
export async function verifyOtp(
email: string,
code: string,
): Promise<
{ ok: true; user: { id: string; email: string } } | { error: string }
> {
const res = await fetch("/api/auth/otp/verify", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email, code }),
});
const data = await parseJson<{
error?: string;
user?: { id: string; email: string };
}>(res);
if (!res.ok || !data.user) {
return { error: readApiErrorMessage(data) };
}
return { ok: true, user: data.user };
}
export async function logout(): Promise<void> {
await fetch("/api/auth/logout", {
method: "POST",
@@ -91,7 +80,9 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
return data.draft.payload as CreateFlowState;
}
export async function saveDraftToServer(state: CreateFlowState): Promise<boolean> {
export async function saveDraftToServer(
state: CreateFlowState,
): Promise<boolean> {
const res = await fetch("/api/drafts/me", {
method: "PUT",
credentials: "include",
@@ -105,10 +96,7 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record<string, unknown>;
}): Promise<
| { ok: true; id: string; title: string }
| { error: string }
> {
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
const res = await fetch("/api/rules", {
method: "POST",
credentials: "include",
+18 -3
View File
@@ -255,12 +255,27 @@ export function normalizeNavigationItemSize(
export function normalizeContentLockupVariant(
value: string | undefined,
defaultValue: "hero" = "hero",
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" {
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" | "login" {
if (!value) return defaultValue;
const normalized = value.toLowerCase();
const variants = ["hero", "feature", "learn", "ask", "ask-inverse", "modal"];
const variants = [
"hero",
"feature",
"learn",
"ask",
"ask-inverse",
"modal",
"login",
];
if (variants.includes(normalized)) {
return normalized as typeof defaultValue;
return normalized as
| "hero"
| "feature"
| "learn"
| "ask"
| "ask-inverse"
| "modal"
| "login";
}
return defaultValue;
}
+7
View File
@@ -0,0 +1,7 @@
/** Allow only same-origin relative paths for open redirects after auth. */
export function safeInternalPath(next: string | null | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) {
return "/";
}
return next;
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
-4
View File
@@ -4,10 +4,6 @@ export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export function hashOtpCode(code: string, pepper: string): string {
return sha256Hex(`${pepper}:otp:${code}`);
}
export function hashSessionToken(token: string, pepper: string): string {
return sha256Hex(`${pepper}:session:${token}`);
}
+7 -4
View File
@@ -1,12 +1,15 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
export async function sendOtpEmail(to: string, code: string): Promise<void> {
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = process.env.SMTP_URL;
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] OTP for ${to}: ${code}`);
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
return;
}
throw new Error("SMTP_URL is not configured");
@@ -18,7 +21,7 @@ export async function sendOtpEmail(to: string, code: string): Promise<void> {
await transporter.sendMail({
from,
to,
subject: "Your Community Rule sign-in code",
text: `Your sign-in code is: ${code}\n\nIt expires in 10 minutes.`,
subject: "Sign in to Community Rule",
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { unstable_noStore as noStore } from "next/cache";
import { isDatabaseConfigured } from "./env";
import { getSessionUser } from "./session";
/**
* Whether the current request has a valid session, for marketing shell SSR.
* Aligns with GET /api/auth/session: no DB → treat as signed out; errors → signed out.
*
* `noStore()` avoids any static/prerender reuse where HTML was built without the request cookie
* but the client still receives `initialSignedIn: true` (hydration mismatch on Log in vs Profile).
*/
export async function getNavAuthSignedIn(): Promise<boolean> {
noStore();
if (!isDatabaseConfigured()) return false;
try {
const user = await getSessionUser();
return user != null;
} catch {
return false;
}
}
+4 -1
View File
@@ -50,7 +50,10 @@ export async function createSessionForUser(
return { token, expiresAt };
}
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
export async function setSessionCookie(
token: string,
expiresAt: Date,
): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
+20 -27
View File
@@ -1,14 +1,8 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
import {
assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS,
} from "./plainJson";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [
string,
...string[],
];
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
const createFlowStepSchema = z.enum(flowStepTuple);
@@ -47,25 +41,24 @@ export const createFlowStateSchema = z
}
});
export const publishRuleBodySchema = z
.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export const publishRuleBodySchema = z.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
+2
View File
@@ -6,6 +6,7 @@
},
"buttons": {
"logIn": "Log in",
"profile": "Profile",
"createRule": "Create rule"
},
"ariaLabels": {
@@ -13,6 +14,7 @@
"mainNavigation": "Main navigation",
"navigateToPage": "Navigate to {text} page",
"logInToAccount": "Log in to your account",
"goToProfile": "Go to your profile",
"createNewRule": "Create a new rule with avatar decoration"
}
}
+4
View File
@@ -13,6 +13,8 @@ import ruleCard from "./components/ruleCard.json";
import ruleStack from "./components/ruleStack.json";
import home from "./pages/home.json";
import learn from "./pages/learn.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
@@ -34,6 +36,8 @@ export default {
pages: {
home,
learn,
login,
profile,
},
create: {
communication,
+22
View File
@@ -0,0 +1,22 @@
{
"title": "Log in to CommunityRule",
"subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!",
"emailLabel": "Email address",
"emailPlaceholder": "you@example.com",
"sendMagicLink": "Send me a magic link",
"successTitle": "Check your email",
"successBody": "We sent a sign-in link. Open it on this device to continue.",
"legalPrefix": "By continuing, you agree to our ",
"legalAnd": " and ",
"legalSuffix": ".",
"backToHome": "← Back to home",
"errors": {
"network": "Something went wrong. Check your connection and try again.",
"emailInvalid": "Enter a valid email address.",
"rateLimited": "Too many requests. Try again in {seconds} seconds.",
"generic": "Something went wrong. Try again.",
"invalidLink": "That sign-in link is not valid. Request a new one from the login page.",
"expiredLink": "That sign-in link has expired. Request a new one from the login page.",
"serverError": "Something went wrong on our end. Try again later."
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"placeholderTitle": "Your profile",
"placeholderBody": "Were building this space for your CommunityRules and account options. Check back soon."
}
+3 -2
View File
@@ -9,6 +9,7 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"start:e2e": "PORT=3010 HOSTNAME=127.0.0.1 node .next/standalone/server.js",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 9999",
"postinstall": "npm rebuild lightningcss && prisma generate",
"storybook": "storybook dev -p 6006",
@@ -32,9 +33,9 @@
"preview": "next build && next start -p 3000",
"e2e:serve": "start-server-and-test preview http://localhost:3000 e2e",
"seed-snapshots": "./scripts/seed-snapshots.sh",
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
"seed-snapshots:local": "npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium --update-snapshots=all",
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "npx playwright test tests/e2e/visual-regression.spec.ts --update-snapshots=all",
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
"analyze": "npm run analyze:browser && npm run analyze:server",
"analyze:server": "ANALYZE=true npm run build",
+6 -4
View File
@@ -21,7 +21,8 @@ export default defineConfig({
reporter: [["list"], ["html", { open: "never" }]],
workers: process.env.CI ? 2 : undefined, // Reduce workers in CI to prevent server overload
use: {
baseURL: process.env.BASE_URL || "http://localhost:3010",
// Prefer 127.0.0.1 so it matches standalone server HOSTNAME and CI (wait-on tcp:127.0.0.1:3010).
baseURL: process.env.BASE_URL || "http://127.0.0.1:3010",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
@@ -38,10 +39,11 @@ export default defineConfig({
? {}
: {
webServer: {
command: "npm run build && npx next start -p 3010",
url: "http://localhost:3010",
// `output: "standalone"` — use the standalone Node server (see Dockerfile), not `next start`.
command: "npm run build && npm run start:e2e",
url: "http://127.0.0.1:3010",
reuseExistingServer: !process.env.CI,
timeout: 180_000, // Increased timeout to account for build time
timeout: 240_000,
},
}),
// Browser-specific snapshot path template (includes projectName for cross-browser support)
@@ -0,0 +1,2 @@
-- Drop legacy email OTP table (product uses magic link only).
DROP TABLE IF EXISTS "OtpChallenge";
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "MagicLinkToken" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"nextPath" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MagicLinkToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MagicLinkToken_tokenHash_key" ON "MagicLinkToken"("tokenHash");
-- CreateIndex
CREATE INDEX "MagicLinkToken_email_idx" ON "MagicLinkToken"("email");
+1 -1
View File
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+3 -3
View File
@@ -29,12 +29,12 @@ model Session {
@@index([userId])
}
model OtpChallenge {
model MagicLinkToken {
id String @id @default(cuid())
email String
codeHash String
tokenHash String @unique
expiresAt DateTime
attempts Int @default(0)
nextPath String?
createdAt DateTime @default(now())
@@index([email])
+1 -1
View File
@@ -33,7 +33,7 @@ docker run --rm -it \
sleep 10
echo '📸 Generating snapshots...'
PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium
npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium --update-snapshots=all
echo '✅ Snapshots generated successfully!'
"
+134
View File
@@ -0,0 +1,134 @@
import React, { Suspense, useEffect } from "react";
import Login from "../../app/components/modals/Login";
import LoginForm from "../../app/components/modals/Login/LoginForm";
/**
* Storybook runs outside Next.js request context; successful "Send link" needs fetch mocked
* because `requestMagicLink` POSTs to `/api/auth/magic-link/request`.
*/
function MagicLinkFetchMock({ children }: { children: React.ReactNode }) {
useEffect(() => {
const orig = globalThis.fetch;
globalThis.fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.includes("/api/auth/magic-link/request")) {
return new Response("{}", {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return orig(input as Request, init);
};
return () => {
globalThis.fetch = orig;
};
}, []);
return <>{children}</>;
}
export default {
title: "Components/Modals/Login",
component: Login,
parameters: {
layout: "fullscreen",
nextjs: {
appDirectory: true,
navigation: {
pathname: "/login",
},
},
docs: {
description: {
component:
"Full-page style login shell (yellow backdrop) with modal card. Uses magic-link `LoginForm` inside. Matches `/login` and header modal usage.",
},
},
},
decorators: [
(Story: () => React.ReactNode) => (
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
<MagicLinkFetchMock>
<Story />
</MagicLinkFetchMock>
</div>
),
],
tags: ["autodocs"],
};
export const ModalChromeOnly = {
name: "Modal (placeholder content)",
render: () => (
<Login
isOpen
onClose={() => {}}
ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
Back to home
</a>
}
>
<p
id="login-modal-heading"
className="font-inter px-2 py-4 text-[var(--color-content-default-primary)]"
>
Placeholder body use &quot;With magic link form&quot; for the real
flow.
</p>
</Login>
),
};
export const WithMagicLinkForm = {
name: "With magic link form",
render: () => (
<Login
isOpen
onClose={() => {}}
ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
>
Back to home
</a>
}
>
<Suspense fallback={<p className="font-inter p-6">Loading</p>}>
<LoginForm />
</Suspense>
</Login>
),
};
export const FormOnly = {
name: "Login form (card inset)",
parameters: {
docs: {
description: {
story:
"Form only, for inspecting copy and layout without the modal chrome. Wrap in `Login` in the app.",
},
},
},
render: () => (
<div className="mx-auto max-w-[560px] rounded-[20px] bg-[var(--color-surface-default-primary)] p-6 shadow-lg">
<Suspense fallback={<p className="font-inter">Loading</p>}>
<LoginForm />
</Suspense>
</div>
),
};
+95
View File
@@ -0,0 +1,95 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import Login from "../../app/components/modals/Login";
describe("Login", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dialog when open and portal is ready", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Login content</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(screen.getByText("Login content")).toBeInTheDocument();
});
it("does not render dialog when closed", () => {
renderWithProviders(
<Login
isOpen={false}
onClose={vi.fn()}
ariaLabelledBy="login-modal-heading"
>
<p>Hidden</p>
</Login>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
renderWithProviders(
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Close dialog"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Escape is pressed", async () => {
const onClose = vi.fn();
renderWithProviders(
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("locks body scroll while open", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(document.body.style.overflow).toBe("hidden");
});
});
it("renders belowCard outside the dialog card", async () => {
renderWithProviders(
<Login
isOpen
onClose={vi.fn()}
ariaLabelledBy="login-modal-heading"
belowCard={<a href="/">Back to home</a>}
>
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(
screen.getByRole("link", { name: /back to home/i }),
).toBeInTheDocument();
});
});
+187
View File
@@ -0,0 +1,187 @@
import React, { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import LoginForm from "../../app/components/modals/Login/LoginForm";
const { navMock } = vi.hoisted(() => ({
navMock: {
searchParams: new URLSearchParams(),
replace: vi.fn(),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: navMock.replace,
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
usePathname: () => "/login",
useSearchParams: () => navMock.searchParams,
}));
vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
import { requestMagicLink } from "../../lib/create/api";
function renderLoginForm() {
return renderWithProviders(
<Suspense fallback={null}>
<LoginForm />
</Suspense>,
);
}
describe("LoginForm", () => {
beforeEach(() => {
vi.mocked(requestMagicLink).mockReset();
navMock.replace.mockReset();
navMock.searchParams = new URLSearchParams();
});
it("renders title, email field, and submit control", () => {
renderLoginForm();
expect(
screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /email address/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /send me a magic link/i }),
).toBeInTheDocument();
});
it("shows validation error when email is invalid", async () => {
const user = userEvent.setup();
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"not-an-email",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/enter a valid email address/i),
).toBeInTheDocument();
expect(requestMagicLink).not.toHaveBeenCalled();
});
it("submits trimmed email and shows success state when API succeeds", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
" Pat@Example.COM ",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith("pat@example.com", "/");
});
expect(
await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
});
it("passes safe next path when next query param is set", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("next=/learn");
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"a@b.co",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith("a@b.co", "/learn");
});
});
it("shows API error when request fails", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Server says no",
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(await screen.findByText("Server says no")).toBeInTheDocument();
});
it("shows rate limit message when retryAfterMs is present", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Too many",
retryAfterMs: 3500,
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/try again in 4 seconds/i),
).toBeInTheDocument();
});
it("shows URL-driven error for expired_link", () => {
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link has expired/i),
).toBeInTheDocument();
});
it("calls router.replace to clear error query when user types", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"x",
);
expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false });
});
it("shows network error when request throws", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockRejectedValue(new Error("network"));
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/check your connection/i),
).toBeInTheDocument();
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 KiB

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 793 KiB

+7 -4
View File
@@ -32,10 +32,13 @@ describe("Page", () => {
).length,
).toBeGreaterThan(0);
// Check feature grid section (using getAllByText since there are multiple instances)
expect(
screen.getAllByText("We've got your back, every step of the way").length,
).toBeGreaterThan(0);
// FeatureGrid is next/dynamic wait like other code-split sections
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
.length,
).toBeGreaterThan(0);
});
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
+4 -1
View File
@@ -21,7 +21,10 @@ describe("assertPlainJsonValue", () => {
});
it("rejects __proto__ keys", () => {
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<string, unknown>;
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<
string,
unknown
>;
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Unsafe object key",
);