diff --git a/.env.example b/.env.example index 0e71b68..1a818e9 100644 --- a/.env.example +++ b/.env.example @@ -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 " diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa18910..b6137ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 app’s 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 diff --git a/app/api/auth/otp/request/route.ts b/app/api/auth/magic-link/request/route.ts similarity index 57% rename from app/api/auth/otp/request/route.ts rename to app/api/auth/magic-link/request/route.ts index 265bfeb..067f2b6 100644 --- a/app/api/auth/otp/request/route.ts +++ b/app/api/auth/magic-link/request/route.ts @@ -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 }, diff --git a/app/api/auth/magic-link/verify/route.ts b/app/api/auth/magic-link/verify/route.ts new file mode 100644 index 0000000..a8e2261 --- /dev/null +++ b/app/api/auth/magic-link/verify/route.ts @@ -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)); +} diff --git a/app/api/auth/otp/verify/route.ts b/app/api/auth/otp/verify/route.ts deleted file mode 100644 index 2212784..0000000 --- a/app/api/auth/otp/verify/route.ts +++ /dev/null @@ -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 }, - }); -} diff --git a/app/api/drafts/me/route.ts b/app/api/drafts/me/route.ts index 51c379e..385f045 100644 --- a/app/api/drafts/me/route.ts +++ b/app/api/drafts/me/route.ts @@ -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, }); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ae2953a..60c4582 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -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 }); } } diff --git a/app/components/modals/Login/Login.container.tsx b/app/components/modals/Login/Login.container.tsx new file mode 100644 index 0000000..9b477c0 --- /dev/null +++ b/app/components/modals/Login/Login.container.tsx @@ -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( + ({ + isOpen, + onClose, + children, + belowCard, + className = "", + ariaLabel, + ariaLabelledBy, + usePortal = true, + }) => { + const dialogRef = useRef(null); + const backdropRef = useRef(null); + const previousActiveElementRef = useRef(null); + const [portalReady, setPortalReady] = useState(() => !usePortal); + + // Defer enabling the portal until after the layout commit so we don’t 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( + '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; + 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 ( + + {children} + + ); + }, +); + +LoginContainer.displayName = "Login"; + +export default LoginContainer; diff --git a/app/components/modals/Login/Login.types.ts b/app/components/modals/Login/Login.types.ts new file mode 100644 index 0000000..dad84b5 --- /dev/null +++ b/app/components/modals/Login/Login.types.ts @@ -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; + backdropRef: React.RefObject; + /** False until client mount — avoids SSR/client HTML mismatch for createPortal. */ + portalReady: boolean; + usePortal: boolean; +} diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx new file mode 100644 index 0000000..d8a38d3 --- /dev/null +++ b/app/components/modals/Login/Login.view.tsx @@ -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 = ( +
+
e.stopPropagation()} + > + +
+ {children} +
+
+ {belowCard ? ( +
e.stopPropagation()}> + {belowCard} +
+ ) : null} +
+ ); + + if (usePortal) { + return createPortal(content, document.body); + } + + return content; +} diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx new file mode 100644 index 0000000..4557ab0 --- /dev/null +++ b/app/components/modals/Login/LoginForm.tsx @@ -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 ( + + + + + ); +} + +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 don’t 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 ( +
+
+
+ +
+ +
+ + {urlErrorMessage ? ( +

+ {urlErrorMessage} +

+ ) : null} + + {formError ? ( + + ) : null} + + {!sent ? ( +
{ + e.preventDefault(); + void sendLink(); + }} + noValidate + > + { + setEmail(e.target.value); + stripErrorQuery(); + }} + disabled={submitting} + error={Boolean(emailError)} + showHelpIcon + /> + {emailError ? ( + + ) : null} + +

+ {t("legalPrefix")} + + {tFooter("legal.termsOfService")} + + {t("legalAnd")} + + {tFooter("legal.privacyPolicy")} + + {t("legalSuffix")} +

+ + ) : null} +
+ ); +} diff --git a/app/components/modals/Login/index.tsx b/app/components/modals/Login/index.tsx new file mode 100644 index 0000000..adf5e5e --- /dev/null +++ b/app/components/modals/Login/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Login.container"; +export type { LoginProps } from "./Login.types"; diff --git a/app/components/navigation/ConditionalFooter.tsx b/app/components/navigation/ConditionalFooter.tsx index 48461d5..570bcf4 100644 --- a/app/components/navigation/ConditionalFooter.tsx +++ b/app/components/navigation/ConditionalFooter.tsx @@ -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; } diff --git a/app/components/navigation/ConditionalNavigation.tsx b/app/components/navigation/ConditionalNavigation.tsx index 7222a60..de5f373 100644 --- a/app/components/navigation/ConditionalNavigation.tsx +++ b/app/components/navigation/ConditionalNavigation.tsx @@ -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 ; -}); - -ConditionalNavigation.displayName = "ConditionalNavigation"; - -export default ConditionalNavigation; +export default async function ConditionalNavigation() { + const initialSignedIn = await getNavAuthSignedIn(); + return ; +} diff --git a/app/components/navigation/ConditionalNavigationClient.tsx b/app/components/navigation/ConditionalNavigationClient.tsx new file mode 100644 index 0000000..f6e322b --- /dev/null +++ b/app/components/navigation/ConditionalNavigationClient.tsx @@ -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 ; + }, +); + +ConditionalNavigationClient.displayName = "ConditionalNavigationClient"; + +export default ConditionalNavigationClient; diff --git a/app/components/navigation/TopNav/TopNav.container.tsx b/app/components/navigation/TopNav/TopNav.container.tsx index 8f72ff9..31ec397 100644 --- a/app/components/navigation/TopNav/TopNav.container.tsx +++ b/app/components/navigation/TopNav/TopNav.container.tsx @@ -139,14 +139,24 @@ const TopNavContainer = memo( 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 ( - {t("buttons.logIn")} + {label} ); }; diff --git a/app/components/navigation/TopNav/TopNavWithPathname.tsx b/app/components/navigation/TopNav/TopNavWithPathname.tsx index 7820ddf..ba6d1e0 100644 --- a/app/components/navigation/TopNav/TopNavWithPathname.tsx +++ b/app/components/navigation/TopNav/TopNavWithPathname.tsx @@ -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 & { + /** 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>((props) => { +const TopNavWithPathname = memo((props) => { + const { initialSignedIn = false, ...topNavRest } = props; const pathname = usePathname(); const isHomePage = pathname === "/"; + const [loggedIn, setLoggedIn] = useState(initialSignedIn); - return ; + 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 ; }); TopNavWithPathname.displayName = "TopNavWithPathname"; diff --git a/app/components/type/ContentLockup/ContentLockup.container.tsx b/app/components/type/ContentLockup/ContentLockup.container.tsx index 1a5c3f4..6f25b72 100644 --- a/app/components/type/ContentLockup/ContentLockup.container.tsx +++ b/app/components/type/ContentLockup/ContentLockup.container.tsx @@ -112,6 +112,20 @@ const ContentLockupContainer = memo( "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; diff --git a/app/components/type/ContentLockup/ContentLockup.types.ts b/app/components/type/ContentLockup/ContentLockup.types.ts index 92efaca..a0bce79 100644 --- a/app/components/type/ContentLockup/ContentLockup.types.ts +++ b/app/components/type/ContentLockup/ContentLockup.types.ts @@ -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"; diff --git a/app/components/type/ContentLockup/ContentLockup.view.tsx b/app/components/type/ContentLockup/ContentLockup.view.tsx index 0b5565a..7e70c87 100644 --- a/app/components/type/ContentLockup/ContentLockup.view.tsx +++ b/app/components/type/ContentLockup/ContentLockup.view.tsx @@ -20,18 +20,21 @@ function ContentLockupView({ }: ContentLockupViewProps) { return (
- {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 */
{subtitle ?

{subtitle}

: null} - {variant === "modal" && description && ( + {(variant === "modal" || variant === "login") && description && (

{description}

)}
diff --git a/app/components/utility/ModalHeader/ModalHeader.view.tsx b/app/components/utility/ModalHeader/ModalHeader.view.tsx index aac6486..4ad3219 100644 --- a/app/components/utility/ModalHeader/ModalHeader.view.tsx +++ b/app/components/utility/ModalHeader/ModalHeader.view.tsx @@ -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 && (
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d3ad9d7 --- /dev/null +++ b/app/login/page.tsx @@ -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 ( +
+

+ Loading… +

+
+ ); +} + +export default function LoginPage() { + return ( + }> + + + ); +} diff --git a/app/profile/ProfilePageClient.tsx b/app/profile/ProfilePageClient.tsx new file mode 100644 index 0000000..3ea1aa8 --- /dev/null +++ b/app/profile/ProfilePageClient.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useTranslation } from "../contexts/MessagesContext"; + +export default function ProfilePageClient() { + const t = useTranslation("pages.profile"); + + return ( +
+

+ {t("placeholderTitle")} +

+

+ {t("placeholderBody")} +

+
+ ); +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..6ed307f --- /dev/null +++ b/app/profile/page.tsx @@ -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 ; +} diff --git a/docs/backend-linear-tickets.md b/docs/backend-linear-tickets.md index 23aeeeb..aefa860 100644 --- a/docs/backend-linear-tickets.md +++ b/docs/backend-linear-tickets.md @@ -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 1–8, 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 1–8, 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 | | ------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1–2 | **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). | | 4–8 | **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 7–8 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 **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 13–14** are parallel to that chain (blocked by **CR-73** and **CR-75** respectively), not sequential after CR-83. +Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Tickets 13–14** 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-72–83 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. diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index 460bf1f..0065de8 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -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 we’re 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 **5–10** 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 consumption — see §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. diff --git a/lib/create/api.ts b/lib/create/api.ts index 7b71d8f..a73159b 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -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 { await fetch("/api/auth/logout", { method: "POST", @@ -91,7 +80,9 @@ export async function fetchDraftFromServer(): Promise { return data.draft.payload as CreateFlowState; } -export async function saveDraftToServer(state: CreateFlowState): Promise { +export async function saveDraftToServer( + state: CreateFlowState, +): Promise { 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; -}): 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", diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index f022601..806ec1f 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -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; } diff --git a/lib/safeInternalPath.ts b/lib/safeInternalPath.ts new file mode 100644 index 0000000..078dc13 --- /dev/null +++ b/lib/safeInternalPath.ts @@ -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; +} diff --git a/lib/server/db.ts b/lib/server/db.ts index 05bd43a..64e0259 100644 --- a/lib/server/db.ts +++ b/lib/server/db.ts @@ -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 ?? diff --git a/lib/server/hash.ts b/lib/server/hash.ts index 9495aa7..b771853 100644 --- a/lib/server/hash.ts +++ b/lib/server/hash.ts @@ -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}`); } diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 261bd7e..bb2960a 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -1,12 +1,15 @@ import nodemailer from "nodemailer"; import { logger } from "../logger"; -export async function sendOtpEmail(to: string, code: string): Promise { +export async function sendMagicLinkEmail( + to: string, + verifyUrl: string, +): Promise { 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 { 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.`, }); } diff --git a/lib/server/navAuth.ts b/lib/server/navAuth.ts new file mode 100644 index 0000000..975e508 --- /dev/null +++ b/lib/server/navAuth.ts @@ -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 { + noStore(); + if (!isDatabaseConfigured()) return false; + try { + const user = await getSessionUser(); + return user != null; + } catch { + return false; + } +} diff --git a/lib/server/session.ts b/lib/server/session.ts index 941ab65..a62bc51 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -50,7 +50,10 @@ export async function createSessionForUser( return { token, expiresAt }; } -export async function setSessionCookie(token: string, expiresAt: Date): Promise { +export async function setSessionCookie( + token: string, + expiresAt: Date, +): Promise { const store = await cookies(); store.set(SESSION_COOKIE_NAME, token, { httpOnly: true, diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 2b67526..c5c1476 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -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; diff --git a/messages/en/components/header.json b/messages/en/components/header.json index 3fe9676..2b193d7 100644 --- a/messages/en/components/header.json +++ b/messages/en/components/header.json @@ -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" } } diff --git a/messages/en/index.ts b/messages/en/index.ts index 3521430..01cbafc 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -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, diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json new file mode 100644 index 0000000..b33ff8a --- /dev/null +++ b/messages/en/pages/login.json @@ -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." + } +} diff --git a/messages/en/pages/profile.json b/messages/en/pages/profile.json new file mode 100644 index 0000000..1be5de4 --- /dev/null +++ b/messages/en/pages/profile.json @@ -0,0 +1,4 @@ +{ + "placeholderTitle": "Your profile", + "placeholderBody": "We’re building this space for your CommunityRules and account options. Check back soon." +} diff --git a/package.json b/package.json index 872e8c5..5aa2b75 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 47e049f..65aa5ea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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) diff --git a/prisma/migrations/20260405120000_remove_otp_challenge/migration.sql b/prisma/migrations/20260405120000_remove_otp_challenge/migration.sql new file mode 100644 index 0000000..f6e3a67 --- /dev/null +++ b/prisma/migrations/20260405120000_remove_otp_challenge/migration.sql @@ -0,0 +1,2 @@ +-- Drop legacy email OTP table (product uses magic link only). +DROP TABLE IF EXISTS "OtpChallenge"; diff --git a/prisma/migrations/20260406015738_add_magic_link_token/migration.sql b/prisma/migrations/20260406015738_add_magic_link_token/migration.sql new file mode 100644 index 0000000..6059290 --- /dev/null +++ b/prisma/migrations/20260406015738_add_magic_link_token/migration.sql @@ -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"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 99e4f20..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 251b5b2..10cf8f8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/scripts/seed-snapshots.sh b/scripts/seed-snapshots.sh index ef5d519..5894d78 100755 --- a/scripts/seed-snapshots.sh +++ b/scripts/seed-snapshots.sh @@ -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!' " diff --git a/stories/modals/Login.stories.tsx b/stories/modals/Login.stories.tsx new file mode 100644 index 0000000..8e21783 --- /dev/null +++ b/stories/modals/Login.stories.tsx @@ -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 => { + 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) => ( +
+ + + +
+ ), + ], + tags: ["autodocs"], +}; + +export const ModalChromeOnly = { + name: "Modal (placeholder content)", + render: () => ( + {}} + ariaLabelledBy="login-modal-heading" + belowCard={ + + ← Back to home + + } + > +

+ Placeholder body — use "With magic link form" for the real + flow. +

+
+ ), +}; + +export const WithMagicLinkForm = { + name: "With magic link form", + render: () => ( + {}} + ariaLabelledBy="login-modal-heading" + belowCard={ + + ← Back to home + + } + > + Loading…

}> + +
+
+ ), +}; + +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: () => ( +
+ Loading…

}> + +
+
+ ), +}; diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx new file mode 100644 index 0000000..793af4d --- /dev/null +++ b/tests/components/Login.test.tsx @@ -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 content

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + expect(screen.getByText("Login content")).toBeInTheDocument(); + }); + + it("does not render dialog when closed", () => { + renderWithProviders( + +

Hidden

+
, + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("calls onClose when close button is clicked", async () => { + const onClose = vi.fn(); + renderWithProviders( + +

Body

+
, + ); + 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( + +

Body

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("locks body scroll while open", async () => { + renderWithProviders( + +

Body

+
, + ); + await waitFor(() => { + expect(document.body.style.overflow).toBe("hidden"); + }); + }); + + it("renders belowCard outside the dialog card", async () => { + renderWithProviders( + Back to home} + > +

Body

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + expect( + screen.getByRole("link", { name: /back to home/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx new file mode 100644 index 0000000..ce912b9 --- /dev/null +++ b/tests/components/LoginForm.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-chromium.png b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-chromium.png index 7f7f8ad..12e50b5 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-chromium.png and b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-chromium.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-firefox.png b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-firefox.png index 10c2e42..4dd3d9d 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-firefox.png and b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-firefox.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-mobile.png b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-mobile.png index 38a4e19..4f1e174 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-mobile.png and b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-mobile.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-webkit.png b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-webkit.png index fb6be59..49d98da 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/404-error-webkit.png and b/tests/e2e/visual-regression.spec.ts-snapshots/404-error-webkit.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-chromium.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-chromium.png index cc46684..77eb036 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-chromium.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-chromium.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-firefox.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-firefox.png index 475dd42..1b4e852 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-firefox.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-firefox.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-mobile.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-mobile.png index fdc8ee4..d4fe06e 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-mobile.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-mobile.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-webkit.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-webkit.png index 22050fe..e656f5e 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-webkit.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-listing-webkit.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-chromium.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-chromium.png index 5bdc92e..322a68a 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-chromium.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-chromium.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-firefox.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-firefox.png index 30764b9..37d4795 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-firefox.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-firefox.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-mobile.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-mobile.png index fefd8e5..966eb10 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-mobile.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-mobile.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-webkit.png b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-webkit.png index f47bdf5..d21e570 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-webkit.png and b/tests/e2e/visual-regression.spec.ts-snapshots/blog-post-webkit.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-chromium.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-chromium.png index 1224f47..f993c2e 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-chromium.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-chromium.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-firefox.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-firefox.png index dd7cb05..bccccbe 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-firefox.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-firefox.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-mobile.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-mobile.png index d30f5ab..4198c35 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-mobile.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-mobile.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-webkit.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-webkit.png index 0e85ee0..eace9f5 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-webkit.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-full-webkit.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-chromium.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-chromium.png index 21e3a96..c6d1c8b 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-chromium.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-chromium.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-firefox.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-firefox.png index 38fe3c8..ef2ab43 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-firefox.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-firefox.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-mobile.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-mobile.png index bf75382..7027358 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-mobile.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-mobile.png differ diff --git a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-webkit.png b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-webkit.png index 05a0dba..a6c81c3 100644 Binary files a/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-webkit.png and b/tests/e2e/visual-regression.spec.ts-snapshots/homepage-viewport-webkit.png differ diff --git a/tests/pages/home.test.jsx b/tests/pages/home.test.jsx index 20fe9d1..485a39e 100644 --- a/tests/pages/home.test.jsx +++ b/tests/pages/home.test.jsx @@ -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.", diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index af7ca6d..78b81a9 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -21,7 +21,10 @@ describe("assertPlainJsonValue", () => { }); it("rejects __proto__ keys", () => { - const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record; + const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record< + string, + unknown + >; expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe( "Unsafe object key", );