diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d4530d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.env* +!.env.example +coverage +playwright-report +test-results +storybook-static +.runner diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a818e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to `.env` for local development (never commit real secrets). + +# PostgreSQL — use `docker compose up -d postgres` and match user/db/password. +DATABASE_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule" + +# 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 the magic-link verify URL to the server console instead of sending email. +SMTP_URL= +SMTP_FROM="Community Rule " + +# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. +NEXT_PUBLIC_ENABLE_BACKEND_SYNC= diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 68b05d3..97d98f2 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -449,6 +449,10 @@ jobs: node-version: "${{ env.NODE_VERSION }}" cache: npm - run: npm ci --no-audit --fund=false + - name: Prisma schema + run: npx prisma validate + env: + DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci - run: npm run lint - run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}" diff --git a/.gitignore b/.gitignore index 99e3022..594818a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b6137ad --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing + +## 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, 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` + +Use `npx prisma studio` to inspect the database. + +### Prisma migrations (important) + +- **Do not edit** migration files that have **already been applied** to **staging, production, or any shared database**. Changing history breaks `migrate deploy` and other environments. +- To fix a bad migration, add a **new** migration that corrects the schema. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §8 for the full policy. + +### API routes (overview) + +| 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 + +Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so the create flow saves drafts to the server when a user is logged in. + +## Frontend / tests + +See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b9c544c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Optional production image (Next.js standalone output + Prisma). +# Build: docker build -t community-rule . +# Run: pass DATABASE_URL, SESSION_SECRET, etc. at runtime (see .env.example). + +FROM node:20-bookworm-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --fund=false + +FROM base AS builder +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM base AS runner +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production +RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 2324721..4ef1aaf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ npm run dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Backend (Postgres, Prisma, API routes) setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md). + ## 🧪 Testing Framework This project uses a simplified, component‑first testing model: diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..2602fb9 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { dbUnavailable } from "../../../../lib/server/responses"; +import { destroySessionFromRequest } from "../../../../lib/server/session"; + +export async function POST() { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + await destroySessionFromRequest(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/magic-link/request/route.ts b/app/api/auth/magic-link/request/route.ts new file mode 100644 index 0000000..067f2b6 --- /dev/null +++ b/app/api/auth/magic-link/request/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +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 MAGIC_LINK_TTL_MS = 15 * 60 * 1000; +const EMAIL_MIN_INTERVAL_MS = 60 * 1000; +const IP_MIN_INTERVAL_MS = 20 * 1000; + +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; +} + +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(); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const email = normalizeEmail( + body && typeof body === "object" && "email" in body + ? (body as { email: unknown }).email + : null, + ); + if (!email) { + return NextResponse.json( + { error: "Valid email required" }, + { status: 400 }, + ); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + + const rlEmail = rateLimitKey(`magic-email:${email}`, EMAIL_MIN_INTERVAL_MS); + if (rlEmail.ok === false) { + return NextResponse.json( + { error: "Too many requests", retryAfterMs: rlEmail.retryAfterMs }, + { status: 429 }, + ); + } + + const rlIp = rateLimitKey(`magic-ip:${ip}`, IP_MIN_INTERVAL_MS); + if (rlIp.ok === false) { + return NextResponse.json( + { error: "Too many requests", retryAfterMs: rlIp.retryAfterMs }, + { status: 429 }, + ); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const token = newSessionToken(); + const tokenHash = hashSessionToken(token, pepper); + const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); + const nextPath = readNextPath(body); + + 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 sendMagicLinkEmail(email, verifyUrl); + } catch (err) { + logger.error("sendMagicLinkEmail failed:", err); + await prisma.magicLinkToken.deleteMany({ where: { email } }); + return NextResponse.json( + { error: "Could not send email" }, + { status: 502 }, + ); + } + + return NextResponse.json({ ok: true }); +} 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/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..430262b --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { dbUnavailable } from "../../../../lib/server/responses"; +import { getSessionUser } from "../../../../lib/server/session"; + +export async function GET() { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ user: null }); + } + + return NextResponse.json({ + user: { id: user.id, email: user.email }, + }); +} diff --git a/app/api/drafts/me/route.ts b/app/api/drafts/me/route.ts new file mode 100644 index 0000000..385f045 --- /dev/null +++ b/app/api/drafts/me/route.ts @@ -0,0 +1,70 @@ +import type { Prisma } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { dbUnavailable } from "../../../../lib/server/responses"; +import { getSessionUser } from "../../../../lib/server/session"; +import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas"; +import { readLimitedJson } from "../../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp"; + +export async function GET() { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const draft = await prisma.ruleDraft.findUnique({ + where: { userId: user.id }, + }); + + return NextResponse.json({ + draft: draft + ? { payload: draft.payload, updatedAt: draft.updatedAt } + : null, + }); +} + +export async function PUT(request: NextRequest) { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = putDraftBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const { payload } = validated.data; + + const jsonPayload = payload as Prisma.InputJsonValue; + + const draft = await prisma.ruleDraft.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + payload: jsonPayload, + }, + update: { + payload: jsonPayload, + }, + }); + + return NextResponse.json({ + draft: { payload: draft.payload, updatedAt: draft.updatedAt }, + }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..60c4582 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../lib/server/env"; + +export async function GET() { + if (!isDatabaseConfigured()) { + return NextResponse.json({ + ok: true, + database: "not_configured", + }); + } + + try { + await prisma.$queryRaw`SELECT 1`; + return NextResponse.json({ ok: true, database: "connected" }); + } catch { + return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); + } +} diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts new file mode 100644 index 0000000..5b9d352 --- /dev/null +++ b/app/api/rules/route.ts @@ -0,0 +1,73 @@ +import type { Prisma } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../lib/server/env"; +import { dbUnavailable } from "../../../lib/server/responses"; +import { getSessionUser } from "../../../lib/server/session"; +import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas"; +import { readLimitedJson } from "../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; + +export async function GET(request: NextRequest) { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const { searchParams } = new URL(request.url); + const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100); + + const rules = await prisma.publishedRule.findMany({ + orderBy: { createdAt: "desc" }, + take, + select: { + id: true, + title: true, + summary: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ rules }); +} + +export async function POST(request: NextRequest) { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = publishRuleBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const { title, summary, document } = validated.data; + + const rule = await prisma.publishedRule.create({ + data: { + userId: user.id, + title, + summary, + document: document as Prisma.InputJsonValue, + }, + }); + + return NextResponse.json({ + rule: { + id: rule.id, + title: rule.title, + summary: rule.summary, + createdAt: rule.createdAt, + }, + }); +} diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts new file mode 100644 index 0000000..28ddada --- /dev/null +++ b/app/api/templates/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../lib/server/env"; +import { dbUnavailable } from "../../../lib/server/responses"; + +/** + * Curated rule templates for recommendations (seed via Prisma Studio or a script). + */ +export async function GET() { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const templates = await prisma.ruleTemplate.findMany({ + orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }], + select: { + id: true, + slug: true, + title: true, + category: true, + description: true, + body: true, + featured: true, + }, + }); + + return NextResponse.json({ templates }); +} 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 && (