adilallo/feature/BackendImplementation1 #43
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
!.env.example
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
storybook-static
|
||||
.runner
|
||||
@@ -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 <noreply@localhost>"
|
||||
|
||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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).
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { LoginView } from "./Login.view";
|
||||
import type { LoginProps } from "./Login.types";
|
||||
|
||||
const LoginContainer = memo<LoginProps>(
|
||||
({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
belowCard,
|
||||
className = "",
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
usePortal = true,
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElementRef = useRef<HTMLElement | null>(null);
|
||||
const [portalReady, setPortalReady] = useState(() => !usePortal);
|
||||
|
||||
// Defer enabling the portal until after the layout commit so we 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<HTMLInputElement>(
|
||||
'input[type="email"]:not([disabled])',
|
||||
);
|
||||
if (emailField) {
|
||||
emailField.focus();
|
||||
return;
|
||||
}
|
||||
const focusableElements = dialog.querySelectorAll(focusableSelector);
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
if (firstElement) {
|
||||
firstElement.focus();
|
||||
} else {
|
||||
dialog.setAttribute("tabindex", "-1");
|
||||
dialog.focus();
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(focusInitial);
|
||||
});
|
||||
}
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||
|
||||
const focusableElements = dialogRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[
|
||||
focusableElements.length - 1
|
||||
] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleTab);
|
||||
previousActiveElementRef.current?.focus();
|
||||
};
|
||||
}, [isOpen, portalReady, usePortal]);
|
||||
|
||||
return (
|
||||
<LoginView
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
belowCard={belowCard}
|
||||
className={className}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaLabelledBy={ariaLabelledBy}
|
||||
dialogRef={dialogRef}
|
||||
backdropRef={backdropRef}
|
||||
portalReady={portalReady}
|
||||
usePortal={usePortal}
|
||||
>
|
||||
{children}
|
||||
</LoginView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LoginContainer.displayName = "Login";
|
||||
|
||||
export default LoginContainer;
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface LoginProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
/** Rendered below the dialog card (e.g. “Back to home”) on the dimmed backdrop */
|
||||
belowCard?: React.ReactNode;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
/**
|
||||
* When false, render the overlay in the React tree instead of `document.body`.
|
||||
* Use on the dedicated `/login` page so the shell (and heading) mount on first paint
|
||||
* without waiting for a portal gate (more reliable across engines).
|
||||
*/
|
||||
usePortal?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
belowCard?: React.ReactNode;
|
||||
className: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
dialogRef: React.RefObject<HTMLDivElement | null>;
|
||||
backdropRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
|
||||
portalReady: boolean;
|
||||
usePortal: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import ModalHeader from "../../utility/ModalHeader";
|
||||
import type { LoginViewProps } from "./Login.types";
|
||||
|
||||
export function LoginView({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
belowCard,
|
||||
className,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
dialogRef,
|
||||
backdropRef,
|
||||
portalReady,
|
||||
usePortal,
|
||||
}: LoginViewProps) {
|
||||
if (!isOpen) return null;
|
||||
if (usePortal && !portalReady) return null;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto bg-[var(--color-surface-inverse-brand-primary)] px-4 py-8"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
||||
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{belowCard ? (
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{belowCard}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (usePortal) {
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Button from "../../buttons/Button";
|
||||
import TextInput from "../../controls/TextInput";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import { requestMagicLink } from "../../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||
|
||||
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
|
||||
function MailIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
aria-hidden
|
||||
data-name="Asset / Icon / mail"
|
||||
>
|
||||
<path
|
||||
fill="#000000"
|
||||
d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000000"
|
||||
d="M22.5 6.908V6.75A2.25 2.25 0 0 0 20.25 4.5h-16.5A2.25 2.25 0 0 0 1.5 6.75v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function LoginForm() {
|
||||
const t = useTranslation("pages.login");
|
||||
const tFooter = useTranslation("footer");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const formAlertId = useId();
|
||||
const emailErrorId = useId();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const nextParam = searchParams.get("next");
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
/** Drop `error` from the URL so URL-driven messages 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 (
|
||||
<div className="flex flex-col gap-6 pt-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<MailIconInline />
|
||||
</div>
|
||||
<ContentLockup
|
||||
titleId="login-modal-heading"
|
||||
title={sent ? t("successTitle") : t("title")}
|
||||
description={sent ? t("successBody") : t("subtitle")}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{urlErrorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{urlErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<p
|
||||
id={formAlertId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!sent ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void sendLink();
|
||||
}}
|
||||
noValidate
|
||||
>
|
||||
<TextInput
|
||||
label={t("emailLabel")}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
stripErrorQuery();
|
||||
}}
|
||||
disabled={submitting}
|
||||
error={Boolean(emailError)}
|
||||
showHelpIcon
|
||||
/>
|
||||
{emailError ? (
|
||||
<p
|
||||
id={emailErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="font-inter text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
|
||||
>
|
||||
{t("sendMagicLink")}
|
||||
</Button>
|
||||
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
|
||||
{t("legalPrefix")}
|
||||
<Link
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.termsOfService")}
|
||||
</Link>
|
||||
{t("legalAnd")}
|
||||
<Link
|
||||
href="#"
|
||||
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
|
||||
>
|
||||
{tFooter("legal.privacyPolicy")}
|
||||
</Link>
|
||||
{t("legalSuffix")}
|
||||
</p>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Login.container";
|
||||
export type { LoginProps } from "./Login.types";
|
||||
@@ -14,13 +14,14 @@ const Footer = dynamic(() => import("./Footer"), {
|
||||
|
||||
/**
|
||||
* Conditionally renders Footer based on pathname.
|
||||
* Hides footer for /create/* routes (full-screen create flow).
|
||||
* Hides footer for /create/* and /login (full-screen flows; login uses a body portal).
|
||||
*/
|
||||
const ConditionalFooter = memo(() => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
if (isCreateFlow) {
|
||||
if (isCreateFlow || isLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
|
||||
import { getNavAuthSignedIn } from "../../../lib/server/navAuth";
|
||||
import ConditionalNavigationClient from "./ConditionalNavigationClient";
|
||||
|
||||
/**
|
||||
* Conditionally renders TopNav based on pathname.
|
||||
* Hides navigation for /create/* routes (full-screen create flow).
|
||||
* Resolves the session on the server so the header matches the HttpOnly cookie on the
|
||||
* first HTML response (no “Log in” flash before `/api/auth/session`).
|
||||
*/
|
||||
const ConditionalNavigation = memo(() => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
|
||||
if (isCreateFlow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopNavWithPathname />;
|
||||
});
|
||||
|
||||
ConditionalNavigation.displayName = "ConditionalNavigation";
|
||||
|
||||
export default ConditionalNavigation;
|
||||
export default async function ConditionalNavigation() {
|
||||
const initialSignedIn = await getNavAuthSignedIn();
|
||||
return <ConditionalNavigationClient initialSignedIn={initialSignedIn} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNavWithPathname from "./TopNav/TopNavWithPathname";
|
||||
|
||||
export type ConditionalNavigationClientProps = {
|
||||
initialSignedIn: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client shell: pathname-based visibility. Session for the first paint comes from the
|
||||
* parent Server Component (`ConditionalNavigation`) via `initialSignedIn`.
|
||||
*/
|
||||
const ConditionalNavigationClient = memo(
|
||||
({ initialSignedIn }: ConditionalNavigationClientProps) => {
|
||||
const pathname = usePathname();
|
||||
const isCreateFlow = pathname?.startsWith("/create");
|
||||
const isLogin = pathname === "/login";
|
||||
|
||||
if (isCreateFlow || isLogin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopNavWithPathname initialSignedIn={initialSignedIn} />;
|
||||
},
|
||||
);
|
||||
|
||||
ConditionalNavigationClient.displayName = "ConditionalNavigationClient";
|
||||
|
||||
export default ConditionalNavigationClient;
|
||||
@@ -139,14 +139,24 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
const isSmallBreakpoint = size === "xsmall" || size === "home";
|
||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
||||
|
||||
const href = loggedIn ? "/profile" : "/login";
|
||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||
const ariaLabel = loggedIn
|
||||
? t("ariaLabels.goToProfile")
|
||||
: t("ariaLabels.logInToAccount");
|
||||
const navSelected =
|
||||
(loggedIn && pathname === "/profile") ||
|
||||
(!loggedIn && pathname === "/login");
|
||||
|
||||
return (
|
||||
<MenuBarItem
|
||||
href="#"
|
||||
href={href}
|
||||
size={sizeMap[size] || "Small"}
|
||||
mode={mode}
|
||||
ariaLabel={t("ariaLabels.logInToAccount")}
|
||||
state={navSelected ? "selected" : "default"}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{t("buttons.logIn")}
|
||||
{label}
|
||||
</MenuBarItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import TopNav from "./TopNav.container";
|
||||
import type { TopNavProps } from "./TopNav.types";
|
||||
import { fetchAuthSession } from "../../../../lib/create/api";
|
||||
|
||||
export type TopNavWithPathnameProps = Omit<TopNavProps, "folderTop"> & {
|
||||
/** From Server Component (`getNavAuthSignedIn`); matches first HTML paint. */
|
||||
initialSignedIn?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* TopNav wrapper that automatically determines folderTop based on current pathname.
|
||||
* Use this in layout.tsx instead of ConditionalHeader.
|
||||
* TopNav wrapper: `folderTop` from pathname; Log in vs Profile from session.
|
||||
*
|
||||
* **SSR:** Parent passes `initialSignedIn` from `getSessionUser()` so the hydrated
|
||||
* header matches the cookie (Next.js pattern for HttpOnly session UI).
|
||||
*
|
||||
* **Client:** Refetch on pathname change (magic-link redirect, stale layout after
|
||||
* `router.refresh()`), **popstate** / **pageshow** `persisted` (bfcache / back).
|
||||
*/
|
||||
const TopNavWithPathname = memo<Omit<TopNavProps, "folderTop">>((props) => {
|
||||
const TopNavWithPathname = memo<TopNavWithPathnameProps>((props) => {
|
||||
const { initialSignedIn = false, ...topNavRest } = props;
|
||||
const pathname = usePathname();
|
||||
const isHomePage = pathname === "/";
|
||||
const [loggedIn, setLoggedIn] = useState(initialSignedIn);
|
||||
|
||||
return <TopNav {...props} folderTop={isHomePage} />;
|
||||
useEffect(() => {
|
||||
setLoggedIn(initialSignedIn);
|
||||
}, [initialSignedIn]);
|
||||
|
||||
const applySessionUser = useCallback(
|
||||
(user: { id: string; email: string } | null) => {
|
||||
setLoggedIn(Boolean(user));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syncSession = useCallback(() => {
|
||||
fetchAuthSession().then(({ user }) => {
|
||||
applySessionUser(user);
|
||||
});
|
||||
}, [applySessionUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchAuthSession().then(({ user }) => {
|
||||
if (!cancelled) applySessionUser(user);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname, applySessionUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPageShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) syncSession();
|
||||
};
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, [syncSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
queueMicrotask(syncSession);
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [syncSession]);
|
||||
|
||||
return <TopNav {...topNavRest} folderTop={isHomePage} loggedIn={loggedIn} />;
|
||||
});
|
||||
|
||||
TopNavWithPathname.displayName = "TopNavWithPathname";
|
||||
|
||||
@@ -112,6 +112,20 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
"font-inter font-normal text-[16px] leading-[24px] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
shape: "w-[16px] h-[16px]",
|
||||
},
|
||||
login: {
|
||||
container:
|
||||
"flex flex-col gap-[var(--spacing-scale-012)] items-start justify-center relative w-full",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)] w-full",
|
||||
titleContainer: "flex items-center justify-start w-full",
|
||||
title:
|
||||
"font-bricolage-grotesque font-extrabold text-[36px] leading-[44px] tracking-[0] text-[var(--color-content-default-primary)] text-left",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
description:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] text-[var(--color-content-default-tertiary)] text-left",
|
||||
shape: "w-[16px] h-[16px]",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant] || variantStyles.hero;
|
||||
|
||||
@@ -5,12 +5,14 @@ export type ContentLockupVariantValue =
|
||||
| "ask"
|
||||
| "ask-inverse"
|
||||
| "modal"
|
||||
| "login"
|
||||
| "Hero"
|
||||
| "Feature"
|
||||
| "Learn"
|
||||
| "Ask"
|
||||
| "Ask-Inverse"
|
||||
| "Modal";
|
||||
| "Modal"
|
||||
| "Login";
|
||||
|
||||
export type ContentLockupAlignmentValue = "center" | "left" | "Center" | "Left";
|
||||
|
||||
@@ -58,7 +60,14 @@ export interface ContentLockupViewProps {
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
buttonClassName: string;
|
||||
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal";
|
||||
variant:
|
||||
| "hero"
|
||||
| "feature"
|
||||
| "learn"
|
||||
| "ask"
|
||||
| "ask-inverse"
|
||||
| "modal"
|
||||
| "login";
|
||||
linkText?: string;
|
||||
linkHref?: string;
|
||||
alignment: "center" | "left";
|
||||
|
||||
@@ -20,18 +20,21 @@ function ContentLockupView({
|
||||
}: ContentLockupViewProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{variant === "ask" || variant === "ask-inverse" || variant === "modal" ? (
|
||||
/* Simplified structure for ask and modal variants */
|
||||
{variant === "ask" ||
|
||||
variant === "ask-inverse" ||
|
||||
variant === "modal" ||
|
||||
variant === "login" ? (
|
||||
/* Simplified structure for ask, modal, and login variants */
|
||||
<div
|
||||
className={`${styles.titleGroup} ${
|
||||
alignment === "left" || variant === "modal"
|
||||
alignment === "left" || variant === "modal" || variant === "login"
|
||||
? "text-left"
|
||||
: "text-center"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.titleContainer} ${
|
||||
alignment === "left" || variant === "modal"
|
||||
alignment === "left" || variant === "modal" || variant === "login"
|
||||
? "justify-start"
|
||||
: "justify-center"
|
||||
}`}
|
||||
@@ -43,7 +46,7 @@ function ContentLockupView({
|
||||
) : null}
|
||||
</div>
|
||||
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
||||
{variant === "modal" && description && (
|
||||
{(variant === "modal" || variant === "login") && description && (
|
||||
<p className={styles.description}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
const iconButtonClass =
|
||||
"absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
|
||||
|
||||
export function ModalHeaderView({
|
||||
onClose,
|
||||
onMoreOptions,
|
||||
@@ -15,8 +18,9 @@ export function ModalHeaderView({
|
||||
{/* Close Button - Left */}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer"
|
||||
className={`${iconButtonClass} left-[24px] top-[12px]`}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
|
||||
@@ -34,8 +38,9 @@ export function ModalHeaderView({
|
||||
{/* More Options Button - Right */}
|
||||
{showMoreOptionsButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreOptions}
|
||||
className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full right-[24px] top-[12px] flex items-center justify-center cursor-pointer"
|
||||
className={`${iconButtonClass} right-[24px] top-[12px]`}
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
fetchDraftFromServer,
|
||||
saveDraftToServer,
|
||||
} from "../../../lib/create/api";
|
||||
import { useCreateFlow } from "./CreateFlowContext";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* When NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true, loads the signed-in user's draft
|
||||
* from the server and debounces saves. Anonymous users keep localStorage-only behavior.
|
||||
*/
|
||||
export function CreateFlowBackendSync() {
|
||||
const { state, replaceState } = useCreateFlow();
|
||||
const [hydrated, setHydrated] = useState(!SYNC_ENABLED);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (cancelled || !user) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const serverDraft = await fetchDraftFromServer();
|
||||
if (cancelled) return;
|
||||
if (serverDraft && Object.keys(serverDraft).length > 0) {
|
||||
replaceState(serverDraft);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [replaceState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED || !hydrated) return;
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
void (async () => {
|
||||
const { user } = await fetchAuthSession();
|
||||
if (!user) return;
|
||||
await saveDraftToServer(state);
|
||||
})();
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [state, hydrated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -79,6 +79,11 @@ export function CreateFlowProvider({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const replaceState = useCallback((next: CreateFlowState) => {
|
||||
setState(next);
|
||||
writeStateToStorage(STORAGE_KEY, next);
|
||||
}, []);
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState({});
|
||||
removeFromStorage(STORAGE_KEY);
|
||||
@@ -89,6 +94,7 @@ export function CreateFlowProvider({
|
||||
state,
|
||||
currentStep,
|
||||
updateState,
|
||||
replaceState,
|
||||
clearState,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CreateFlowBackendSync } from "./context/CreateFlowBackendSync";
|
||||
import {
|
||||
CreateFlowProvider,
|
||||
useCreateFlow,
|
||||
@@ -107,6 +108,7 @@ export default function CreateFlowLayout({
|
||||
}) {
|
||||
return (
|
||||
<CreateFlowProvider>
|
||||
<CreateFlowBackendSync />
|
||||
<CreateFlowLayoutContent>{children}</CreateFlowLayoutContent>
|
||||
</CreateFlowProvider>
|
||||
);
|
||||
|
||||
@@ -21,11 +21,19 @@ export type CreateFlowStep =
|
||||
| "completed";
|
||||
|
||||
/**
|
||||
* Flow state interface for storing user inputs across all steps
|
||||
* Will be expanded in CR-56 with specific field definitions
|
||||
* Flow state for inputs across create-flow steps.
|
||||
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||
* Additional string keys are allowed at runtime for forward-compatible step data.
|
||||
*/
|
||||
export interface CreateFlowState {
|
||||
// Placeholder structure - will be expanded in CR-56
|
||||
title?: string;
|
||||
summary?: string;
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
|
||||
stakeholders?: Record<string, unknown>[];
|
||||
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -37,6 +45,8 @@ export interface CreateFlowContextValue {
|
||||
state: CreateFlowState;
|
||||
currentStep: CreateFlowStep | null;
|
||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||
/** Replace entire flow state (e.g. hydrate from server draft). */
|
||||
replaceState: (_next: CreateFlowState) => void;
|
||||
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import "./globals.css";
|
||||
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
|
||||
import ConditionalFooter from "./components/navigation/ConditionalFooter";
|
||||
|
||||
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
|
||||
export default function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.login");
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import LoginPageClient from "./LoginPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log in · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
function LoginFallback() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-inverse-brand-primary)] flex items-center justify-center">
|
||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
|
||||
export default function ProfilePageClient() {
|
||||
const t = useTranslation("pages.profile");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
|
||||
<h1 className="font-bricolage text-3xl font-extrabold text-[var(--color-content-default-primary)] md:text-4xl">
|
||||
{t("placeholderTitle")}
|
||||
</h1>
|
||||
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
|
||||
{t("placeholderBody")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import ProfilePageClient from "./ProfilePageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfilePageClient />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: communityrule
|
||||
POSTGRES_PASSWORD: communityrule
|
||||
POSTGRES_DB: communityrule
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
# Official image is amd64-only; pin platform so Apple Silicon uses emulation cleanly.
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,516 @@
|
||||
# Backend work — linear tickets
|
||||
|
||||
Copy each block into Linear (or your tracker) as a separate issue, **in order**. Earlier tickets are prerequisites for later ones.
|
||||
|
||||
**Foundation already in the repo (no ticket needed unless you are onboarding a greenfield clone):** Prisma schema ([prisma/schema.prisma](prisma/schema.prisma)), migrations, `lib/server/*`, Route Handlers under `app/api/*`, [docker-compose.yml](docker-compose.yml), [Dockerfile](Dockerfile), [CONTRIBUTING.md](CONTRIBUTING.md), [`.env.example`](.env.example), [lib/create/api.ts](lib/create/api.ts), [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) behind `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`.
|
||||
|
||||
### 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), **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.
|
||||
|
||||
---
|
||||
|
||||
## When you need server / admin access (and for what)
|
||||
|
||||
Use this if you **do not** have SSH or hosting access yet. Most engineering tickets are **local-only** until you deploy somewhere shared.
|
||||
|
||||
### 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`. **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
|
||||
|
||||
That is when you deploy to **staging** or **production** (a URL other people use, or a persistent DB not on your laptop). Until then, you can finish the core product slice without server credentials.
|
||||
|
||||
Ask the admin to provide (or do for you) the items below—**Ticket 12** turns this into a written runbook.
|
||||
|
||||
| What | Why you need it |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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 **+ 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. |
|
||||
| **Backups** | Postgres backup/restore for production (and ideally staging). |
|
||||
|
||||
Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admin builds/pushes/runs the container with the env vars above.
|
||||
|
||||
### Ticket-by-ticket: admin involvement
|
||||
|
||||
| Ticket | Need server admin? | What for |
|
||||
| ------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1–2 | **No** | Docs and app code only. |
|
||||
| 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. |
|
||||
| 11 | **Maybe** | Whoever owns **Gitea runners**: can they run Postgres in CI? Not the same as production server, but often the same “infra” person. |
|
||||
| 12 | **Yes—this is the handoff ticket** | You (or admin) write **`docs/ops-backend-deploy.md`** so deploy steps are explicit; **you need admin input** to fill in hostnames, DB provider, SMTP, backup policy. |
|
||||
|
||||
### One-line summary
|
||||
|
||||
**You only need the server admin when you move off your laptop to a shared staging/production host**—for database, secrets, TLS, SMTP/DNS, migrations on that DB, health checks, and backups. Until then, **Tickets 1–8 are unblocked** with Docker Compose locally.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 1 — Align `docs/backend-roadmap.md` with the current codebase
|
||||
|
||||
**Depends on:** nothing.
|
||||
|
||||
**Goal:** Remove stale statements so the roadmap matches reality and stays a trustworthy reference until you delete it.
|
||||
|
||||
**Context:** Section 1 still says there is no DB and only web-vitals API; the app now has Postgres models, auth, drafts, rules, templates API, etc.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Rewrite **§1 Where we are** to list: Prisma + Postgres, existing `app/api/*` routes, `localStorage` + optional server draft sync, web-vitals still file-based.
|
||||
2. In **§9 Build order** (build steps were renumbered from old §5), mark what is **operator/manual**, what is **already shipped in the repo**, and what is **still product/frontend** (sign-in UI, publish wiring, etc.).
|
||||
3. Add **HTTP API (implemented in repo)** — table mirroring [CONTRIBUTING.md](CONTRIBUTING.md), plus note for `/api/web-vitals`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] A new contributor reading only the roadmap does not think the backend is unbuilt.
|
||||
- [x] **§13 Optional later** (old §9) unchanged in intent — optional Redis, session-library spike, draft versioning, standalone API, OpenAPI, fourth env.
|
||||
|
||||
**Status:** [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) **Done**.
|
||||
|
||||
**Files:** [docs/backend-roadmap.md](docs/backend-roadmap.md) only.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 2 — Formalize `CreateFlowState` and validate API payloads
|
||||
|
||||
**Depends on:** Ticket 1 (optional but keeps docs honest).
|
||||
|
||||
**Goal:** Replace the open `[key: string]: unknown` shape in [app/create/types.ts](app/create/types.ts) with real fields (or nested objects) agreed with design/product, and validate JSON on the server for drafts and publish.
|
||||
|
||||
**Context:** `PUT /api/drafts/me` and `POST /api/rules` accept loose objects today; oversized or malformed payloads are a stability and security concern.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Document intended fields per create-flow step (can start minimal: e.g. `title`, `sections`, `stakeholders` placeholders) in `CreateFlowState`.
|
||||
2. Add **Zod** (or reuse **Ajv** if you prefer consistency with [lib/validation.ts](lib/validation.ts)) schemas:
|
||||
- `createFlowStateSchema` for draft `payload`.
|
||||
- `publishedRuleDocumentSchema` for `document` on `POST /api/rules`.
|
||||
3. In [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts) and [app/api/rules/route.ts](app/api/rules/route.ts), parse with schema; on failure return `400` with a small `{ error, details? }` body.
|
||||
4. Enforce a **max payload size** (e.g. reject bodies > 512KB) via route handler check or Next config if applicable.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [x] TypeScript reflects the real shape of `CreateFlowState` (no unnecessary `unknown` for known keys).
|
||||
- [x] Invalid draft/publish requests return 400, not 500.
|
||||
- [x] Unit tests for schemas (Vitest) or route tests with MSW.
|
||||
|
||||
**Status:** [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) **Done**.
|
||||
|
||||
**Files:** [app/create/types.ts](app/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), [lib/server/validation/](lib/server/validation/) (Zod + plain-JSON checks), [package.json](package.json) (`zod`).
|
||||
|
||||
**Note:** Repo-wide **API error JSON shape** and **request-id logging** are **Ticket 13 / CR-84**—coordinate 400 response bodies with that issue so validation errors match the agreed `{ error: { code, message } }` pattern.
|
||||
|
||||
---
|
||||
|
||||
## 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 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 **sign-in link** and complete sign-in in the browser using existing endpoints.
|
||||
|
||||
**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 (shipped):**
|
||||
|
||||
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:**
|
||||
|
||||
- [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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 4 — Session affordances in the create flow (signed-in state + sign out)
|
||||
|
||||
**Depends on:** Ticket 3.
|
||||
|
||||
**Goal:** While in `/create/*`, users see whether they are signed in and can sign out without leaving the flow awkwardly.
|
||||
|
||||
**Context:** [CreateFlowTopNav](app/components/utility/CreateFlowTopNav/) has props like `loggedIn` currently tied to step UI in [app/create/layout.tsx](app/create/layout.tsx) (`isCompletedStep`). Decouple **auth session** from **step**.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. On create layout mount (or a small wrapper provider), call `fetchAuthSession()` and store `{ user }` in React state or a tiny `AuthSessionContext`.
|
||||
2. Pass **real** `loggedIn={Boolean(user)}` (or rename prop to `isAuthenticated` if clearer) and show **email** (truncated) per design.
|
||||
3. Wire **Sign out** to `logout()` from [lib/create/api.ts](lib/create/api.ts), clear client state as needed, refresh session.
|
||||
4. Optionally: if `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` and user is anonymous, show one-line CTA “Sign in to save progress to your account” linking to login.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Completed step still works; auth state is independent of `completed` step.
|
||||
- [ ] Sign out clears httpOnly session server-side and UI updates.
|
||||
|
||||
**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), optional new `app/create/context/AuthSessionContext.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 5 — Harden server draft sync (UX + edge cases)
|
||||
|
||||
**Depends on:** Tickets 2–4.
|
||||
|
||||
**Goal:** `CreateFlowBackendSync` is production-grade when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||
|
||||
**Context:** [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx) hydrates from server and debounces saves; today it can race with localStorage-first paint and silently fail saves.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled).
|
||||
2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment.
|
||||
3. **Save failures (API surface):** Change [saveDraftToServer](lib/create/api.ts) from `Promise<boolean>` to a result type such as `{ ok: true } | { ok: false; message: string; status?: number }`, parsing the response body with [readApiErrorMessage](lib/create/api.ts) so both legacy `{ error: string }` and CR-73 validation `{ error: { message } }` (and 413 `payload_too_large`) produce a useful `message`. Update [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) to branch on that result.
|
||||
4. **Save failures (UX):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff.
|
||||
5. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] No silent data loss when server save fails.
|
||||
- [ ] User understands when server draft replaced local state (if applicable).
|
||||
|
||||
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 6 — Wire “Publish rule” from the create flow to `POST /api/rules`
|
||||
|
||||
**Depends on:** Tickets 2–4 (Ticket 5 optional).
|
||||
|
||||
**Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts).
|
||||
|
||||
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on [app/create/final-review/page.tsx](app/create/final-review/page.tsx) or [completed/page.tsx](app/create/completed/page.tsx) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Map `useCreateFlow().state` → `title` / `summary` / `document` (document likely mirrors [CommunityRuleDocument](app/components/sections/CommunityRuleDocument/) shape or raw JSON).
|
||||
2. Call `publishRule` on explicit user action (“Publish” / “Finalize”) or on transition to `completed` (product decision—prefer explicit button to avoid double-submit).
|
||||
3. Handle **401**: redirect or modal to sign-in (Ticket 3).
|
||||
4. Success: navigate to `completed` with rule id in query or state; optional confetti per design.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Published row appears in Postgres (`PublishedRule`) and `GET /api/rules` lists it.
|
||||
- [ ] User sees clear success/failure.
|
||||
|
||||
**Files:** relevant `app/create/*/page.tsx`, [lib/create/api.ts](lib/create/api.ts) if request shape changes, types from Ticket 2.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 7 — Seed `RuleTemplate` data and document how to re-run
|
||||
|
||||
**Depends on:** none (API exists at [app/api/templates/route.ts](app/api/templates/route.ts)).
|
||||
|
||||
**Goal:** Curated templates exist in DB for recommendations (v1 = static curated list, no ML).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Add [Prisma seed](https://www.prisma.io/docs/guides/migrate/seed-database): `prisma/seed.ts` with `upsert` on `slug` for idempotent runs.
|
||||
2. In [package.json](package.json), set `"prisma": { "seed": "tsx prisma/seed.ts" }` or `node --loader ts-node/esm` per your preference.
|
||||
3. Seed 3–10 rows aligned with marketing copy today ([messages/en/components/ruleStack.json](messages/en/components/ruleStack.json) or home cards) — `title`, `category`, `description`, `body` JSON, `sortOrder`, `featured`.
|
||||
4. Document: `npx prisma db seed` in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] `GET /api/templates` returns non-empty `templates` after seed on empty DB.
|
||||
- [ ] Re-running seed does not duplicate rows.
|
||||
|
||||
**Files:** `prisma/seed.ts`, [package.json](package.json), [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Ticket 8 — Load rule templates from the API in the UI
|
||||
|
||||
**Depends on:** Ticket 7.
|
||||
|
||||
**Goal:** Home or create entry surfaces use live template data instead of only static i18n JSON.
|
||||
|
||||
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and [app/create/[step]/page.tsx](app/create/[step]/page.tsx) placeholders reference future template work (CR-51–55).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Add a small client or server data fetch to `GET /api/templates` (RSC `fetch` with cache tags, or client `useEffect` with loading skeleton—match existing data-fetch patterns in the app).
|
||||
2. Map API rows to existing card components; keep i18n for chrome strings (“See all templates”).
|
||||
3. Empty state: if API returns `[]`, fall back to static copy or hide section per design.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Changing a template row in Prisma Studio reflects after refresh (or revalidate).
|
||||
- [ ] No layout shift regression on LCP-critical pages (use skeletons).
|
||||
|
||||
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), [app/create/[step]/page.tsx](app/create/[step]/page.tsx) or related, possibly new `lib/templates/fetchTemplates.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
|
||||
|
||||
**Depends on:** none (orthogonal).
|
||||
|
||||
**Server / admin:** **Not required** to implement. **Relevant** when production is **multi-instance** or **read-only filesystem**; external tools may need **vendor API keys** in env.
|
||||
|
||||
**Goal:** [app/api/web-vitals/route.ts](app/api/web-vitals/route.ts) stops relying on ephemeral files under `.next/web-vitals` in production.
|
||||
|
||||
**Context:** Multi-instance / Docker loses file-based metrics. [docs/backend-roadmap.md](backend-roadmap.md) §7: **default** is **external** analytics or log drain—keep product Postgres for product data.
|
||||
|
||||
**Implementation (pick one — prefer A or B first):**
|
||||
|
||||
- **A (preferred):** Integrate **external RUM / logging** (host metrics, Vercel Web Analytics, OpenTelemetry export, Datadog, etc.); stop or thin local aggregation; `app/(admin)/monitor/page.tsx` links out or shows reduced scope.
|
||||
- **B:** Forward events from the route to a **log drain** or APM; trim custom dashboard if redundant.
|
||||
- **C (fallback only):** New Prisma model `WebVitalEvent` + migrate + read path in monitor—**only** if ops explicitly chooses a single-store tradeoff (document why).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Production with read-only filesystem does not break vitals collection path.
|
||||
- [ ] Monitor page still useful or intentionally replaced with a doc link.
|
||||
|
||||
**Files:** [app/api/web-vitals/route.ts](app/api/web-vitals/route.ts), [app/(admin)/monitor/](<app/(admin)/monitor/page.tsx>) (adjust paths as needed), optionally `prisma/schema.prisma` **only if** option C.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 10 — Public rule detail (optional product scope)
|
||||
|
||||
**Depends on:** Ticket 6.
|
||||
|
||||
**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).
|
||||
2. Add `app/(marketing)/rules/[id]/page.tsx` (or under `create` if private) rendering `document` JSON with existing document components.
|
||||
3. Consider soft-delete flag later; out of scope unless product requires hide.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] UUID/cuid from Ticket 6 opens a readable page for anonymous users.
|
||||
- [ ] Invalid id returns 404.
|
||||
|
||||
**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)
|
||||
|
||||
**Depends on:** existing [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml).
|
||||
|
||||
**Server / admin:** **Not production server**—but you may need whoever runs **Gitea/self-hosted runners** to allow **Postgres in CI** (Docker service / sidecar) or to accept a **manual migrate** process documented instead.
|
||||
|
||||
**Goal:** Catch broken SQL migrations before merge.
|
||||
|
||||
**Context:** Lint job already runs `prisma validate` with a dummy `DATABASE_URL`. **Migrate** needs a real Postgres reachable from the runner.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. If Gitea runners support **Docker sidecar** or **postgres service**, add a job: start Postgres, set `DATABASE_URL`, `npx prisma migrate deploy`, then run a minimal test that hits `/api/health` with DB connected (may require `next build` + short `next start` + curl).
|
||||
2. If **macOS self-hosted** runners cannot run service containers easily, document in CONTRIBUTING: “run `migrate deploy` against staging before prod” and keep validate-only in CI.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Broken migration fails CI **or** documented alternative process is explicit.
|
||||
|
||||
**Files:** [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml), [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Ticket 12 — Staging / production runbook (operator checklist)
|
||||
|
||||
**Depends on:** Tickets 1–8 complete enough to deploy a vertical slice.
|
||||
|
||||
**Server / admin:** **This is the main ticket where you need the admin.** You draft the runbook; **admin fills in real hostnames, DB endpoint, SMTP, backup tooling, and who runs `migrate deploy`.** Without their input, you cannot complete production-ready deploy steps.
|
||||
|
||||
**Goal:** Single doc for admin: env vars, TLS, DB backups, migrations, Docker, SMTP, health checks.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Add `docs/ops-backend-deploy.md` (or similar) with numbered steps:
|
||||
- Required env: `DATABASE_URL`, `SESSION_SECRET`, `SMTP_URL`, `SMTP_FROM`, optional `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`.
|
||||
- `docker compose` vs `Dockerfile` deploy; `prisma migrate deploy` before traffic.
|
||||
- Reverse proxy: `GET /api/health` for LB health.
|
||||
- Backups and restore drill for Postgres.
|
||||
- SMTP DNS (SPF/DKIM).
|
||||
2. Cross-link [docs/backend-roadmap.md](docs/backend-roadmap.md) §11 (environments) and §8 (migrations policy); note **never rewrite applied migrations** and where application logs go.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Someone who did not write the code can deploy and roll back migrations with only the doc.
|
||||
|
||||
**Files:** new `docs/ops-backend-deploy.md`.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 13 — API error contract + structured logging
|
||||
|
||||
**Depends on:** Ticket 2 (validation work defines many 400s).
|
||||
|
||||
**Server / admin:** None.
|
||||
|
||||
**Goal:** Standardize JSON errors and lightweight observability per [docs/backend-roadmap.md](backend-roadmap.md) §7.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Document target shape `{ error: { code, message }, details? }` and map validation failures into `details` where useful.
|
||||
2. Add a small **route helper** or wrapper: generate or forward **`x-request-id`**, log errors with `lib/logger` + id.
|
||||
3. Migrate high-traffic or auth routes first; follow-up pass for remaining `app/api/*`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] At least auth + draft + rules routes return the agreed shape for new code paths.
|
||||
- [ ] Errors in logs include request id when available.
|
||||
|
||||
**Files:** `lib/server/` (new helper), selected `app/api/**/route.ts`, optional tests.
|
||||
|
||||
**Linear:** [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) (blocked by **CR-73**).
|
||||
|
||||
---
|
||||
|
||||
## Ticket 14 — Custom session lifecycle (rotation, cleanup, policy)
|
||||
|
||||
**Depends on:** Ticket 4 (session visible in create flow).
|
||||
|
||||
**Server / admin:** None for implementation; production cron may need admin if cleanup runs as a job.
|
||||
|
||||
**Goal:** Make custom Prisma sessions maintainable: rotation, invalidation policy, expired-row cleanup—per [docs/backend-roadmap.md](backend-roadmap.md) §4–5.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
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`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] 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/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 | 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. **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), **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) | 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.
|
||||
@@ -0,0 +1,257 @@
|
||||
# Backend roadmap (reference)
|
||||
|
||||
Temporary working notes for building the backend. Safe to delete once the stack is stable.
|
||||
|
||||
---
|
||||
|
||||
## 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/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).
|
||||
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
||||
|
||||
### HTTP API (implemented in repo)
|
||||
|
||||
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/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
|
||||
|
||||
**Step 1.** Treat this as **greenfield**: new **PostgreSQL** database and new tables. Do **not** migrate data from the old Community Rule backend.
|
||||
|
||||
**Step 2.** Keep the backend **inside this Next app**:
|
||||
|
||||
- HTTP handlers under `app/api/…`
|
||||
- Shared server code under `lib/server/…`
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stack choices
|
||||
|
||||
**Step 1.** Use **PostgreSQL** everywhere (local Docker, staging, production).
|
||||
|
||||
**Step 2.** Use **Prisma** — `schema.prisma`, `npx prisma migrate dev` / `migrate deploy`.
|
||||
|
||||
**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 **passwordless email (magic-link request)**; the current limiter is in-memory per process ([`lib/server/rateLimit.ts`](lib/server/rateLimit.ts)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Data to model (first pass)
|
||||
|
||||
Plain-English entities (names can evolve):
|
||||
|
||||
| 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.
|
||||
|
||||
**RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them.
|
||||
|
||||
Align JSON shapes with `app/create/types.ts` as it matures.
|
||||
|
||||
---
|
||||
|
||||
## 5. Session and authentication (v1)
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Authorization (v1)
|
||||
|
||||
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 (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.
|
||||
|
||||
---
|
||||
|
||||
## 7. API responses, errors, and observability
|
||||
|
||||
**Error JSON (target):** Prefer a stable shape, e.g. `{ "error": { "code": "string", "message": "string" }, "details"?: ... }` for 4xx/5xx, instead of only `{ "error": "string" }`. Validation errors can map into `details`. Implement gradually in route handlers.
|
||||
|
||||
**Logging:** Use the shared [`lib/logger.ts`](../lib/logger.ts) where possible. Include a **request correlation id** (reuse `x-request-id` if present, else generate) on API routes and log it with errors so support can tie logs together.
|
||||
|
||||
**Metrics:** No vendor required for v1; optional later: request duration, error counts.
|
||||
|
||||
**Web vitals:** **Default** is **external** RUM or log drain (e.g. host analytics, Vercel Analytics, OpenTelemetry, SaaS APM)—keep **product Postgres** focused on product entities. Storing vitals in Postgres is an **explicit tradeoff** only if ops strongly wants a single datastore.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prisma migrations policy
|
||||
|
||||
- **Never edit** migration files that have **already been applied** to **staging or production** (or any shared database). Fixing schema drift = **add a new migration**.
|
||||
- **Local dev:** `prisma migrate dev` creates migrations; **deployed envs:** `prisma migrate deploy` before serving new code that depends on the schema.
|
||||
|
||||
---
|
||||
|
||||
## 9. Build order (implementation steps)
|
||||
|
||||
**Operator / local (always manual):** Steps 1–4 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`.
|
||||
|
||||
**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, **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
|
||||
---
|
||||
|
||||
**Step 1.** Copy `.env.example` to `.env`. Set `DATABASE_URL` and secrets (see file comments).
|
||||
|
||||
**Step 2.** Start Postgres locally:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
**Step 3.** Install dependencies and apply migrations:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
**Step 4.** Run the app:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Step 5.** Confirm **health**: `GET /api/health` should return JSON.
|
||||
|
||||
**Step 6.** **Magic-link sign-in** (happy path):
|
||||
|
||||
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).
|
||||
|
||||
**Step 8.** **Publish**: `POST /api/rules` with `{ "title", "summary?", "document" }`.
|
||||
|
||||
**Step 9.** **Templates** (when ready): seed `RuleTemplate` rows; `GET /api/templates` is implemented.
|
||||
|
||||
**Step 10.** **Frontend sync**: Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` for server drafts when logged in; `localStorage` remains fallback when off or anonymous.
|
||||
|
||||
**Step 11.** **Web vitals:** Move off `.next` files—**prefer an external analytics or logging pipeline** (see §7). Use Postgres for vitals only as a deliberate ops choice.
|
||||
|
||||
---
|
||||
|
||||
## 10. Security checklist
|
||||
|
||||
- **HTTPS** in staging/production; session cookie **Secure**.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Environments
|
||||
|
||||
| Environment | Purpose | Notes |
|
||||
| -------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| **Local** | Daily development | Docker Compose: Postgres + optional Mailhog (`docker compose up -d`). |
|
||||
| **Staging** | Rehearse deploys and migrations | Match prod as closely as possible; test SMTP. |
|
||||
| **Production** | Users | Backups, monitoring, migration job before traffic. |
|
||||
|
||||
**Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server.
|
||||
|
||||
**Admin / infra (coordinate with whoever runs the server):**
|
||||
|
||||
1. TLS certificates and hostnames.
|
||||
2. PostgreSQL backups and restore drill.
|
||||
3. SMTP DNS (SPF, DKIM).
|
||||
4. Health check URL for reverse proxy (`/api/health`).
|
||||
5. Log retention and alerts for 5xx errors.
|
||||
|
||||
---
|
||||
|
||||
## 12. Frontend hook-up
|
||||
|
||||
**Step 1.** Keep default behavior: **no env flag** → create flow uses **only** `localStorage` (current behavior).
|
||||
|
||||
**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to opt in to server drafts when logged in.
|
||||
|
||||
**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 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.
|
||||
- **Fourth environment** or stricter rate limiting at the edge.
|
||||
|
||||
---
|
||||
|
||||
## 14. Useful commands
|
||||
|
||||
| Command | When |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `npx prisma studio` | Inspect/edit DB locally. |
|
||||
| `npx prisma migrate dev` | After changing `schema.prisma` in development. |
|
||||
| `npx prisma migrate deploy` | Apply migrations in staging/production. |
|
||||
| `docker compose up -d postgres mailhog` | Local DB + mail UI (http://localhost:8025). |
|
||||
| `docker build -t community-rule .` | Optional production image (Next **standalone** + `node server.js`; see repo `Dockerfile`). |
|
||||
|
||||
---
|
||||
|
||||
## External reading
|
||||
|
||||
- [Docker: development best practices](https://docs.docker.com/develop/dev-best-practices/)
|
||||
- [Docker Compose in production](https://docs.docker.com/compose/how-tos/production/)
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json()) as T;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Supports legacy `{ error: string }` and `{ error: { message: string } }` from API routes. */
|
||||
function readApiErrorMessage(data: unknown): string {
|
||||
if (!data || typeof data !== "object" || !("error" in data)) {
|
||||
return "Request failed";
|
||||
}
|
||||
const err = (data as { error: unknown }).error;
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err && typeof err === "object" && "message" in err) {
|
||||
const m = (err as { message: unknown }).message;
|
||||
if (typeof m === "string") {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
|
||||
export async function fetchAuthSession(): Promise<{
|
||||
user: { id: string; email: string } | null;
|
||||
}> {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { user: null };
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
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,
|
||||
...(nextPath ? { next: nextPath } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs:
|
||||
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||
const res = await fetch("/api/drafts/me", { credentials: "include" });
|
||||
if (res.status === 401) return null;
|
||||
if (!res.ok) return null;
|
||||
const data = await parseJson<{ draft: { payload: unknown } | null }>(res);
|
||||
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
return data.draft.payload as CreateFlowState;
|
||||
}
|
||||
|
||||
export async function saveDraftToServer(
|
||||
state: CreateFlowState,
|
||||
): Promise<boolean> {
|
||||
const res = await fetch("/api/drafts/me", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ payload: state }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function publishRule(input: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
document: Record<string, unknown>;
|
||||
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
document: input.document,
|
||||
}),
|
||||
});
|
||||
const data = await parseJson<{
|
||||
error?: string;
|
||||
rule?: { id: string; title: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.rule) {
|
||||
return { error: readApiErrorMessage(data) };
|
||||
}
|
||||
return { ok: true, id: data.rule.id, title: data.rule.title };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function getSessionPepper(): string {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error(
|
||||
"SESSION_SECRET must be set (min 16 characters) for auth routes.",
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured(): boolean {
|
||||
return Boolean(process.env.DATABASE_URL?.trim());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return createHash("sha256").update(input, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string, pepper: string): string {
|
||||
return sha256Hex(`${pepper}:session:${token}`);
|
||||
}
|
||||
|
||||
export function newSessionToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
import { getSessionUser } from "./session";
|
||||
|
||||
/**
|
||||
* Whether the current request has a valid session, for marketing shell SSR.
|
||||
* Aligns with GET /api/auth/session: no DB → treat as signed out; errors → signed out.
|
||||
*
|
||||
* `noStore()` avoids any static/prerender reuse where HTML was built without the request cookie
|
||||
* but the client still receives `initialSignedIn: true` (hydration mismatch on Log in vs Profile).
|
||||
*/
|
||||
export async function getNavAuthSignedIn(): Promise<boolean> {
|
||||
noStore();
|
||||
if (!isDatabaseConfigured()) return false;
|
||||
try {
|
||||
const user = await getSessionUser();
|
||||
return user != null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* In-memory rate limiter (per server instance). Sufficient for small deploys;
|
||||
* replace with Redis or edge limits if you scale horizontally.
|
||||
*/
|
||||
|
||||
const windows = new Map<string, number>();
|
||||
|
||||
export function rateLimitKey(
|
||||
key: string,
|
||||
minIntervalMs: number,
|
||||
): { ok: true } | { ok: false; retryAfterMs: number } {
|
||||
const now = Date.now();
|
||||
const last = windows.get(key) ?? 0;
|
||||
const elapsed = now - last;
|
||||
if (elapsed < minIntervalMs) {
|
||||
return { ok: false, retryAfterMs: minIntervalMs - elapsed };
|
||||
}
|
||||
windows.set(key, now);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function dbUnavailable(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: "Database is not configured (DATABASE_URL)." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { User } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { getSessionPepper } from "./env";
|
||||
import { hashSessionToken, newSessionToken } from "./hash";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "cr_session";
|
||||
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
|
||||
|
||||
export async function getSessionUser(): Promise<User | null> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function createSessionForUser(
|
||||
userId: string,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const pepper = getSessionPepper();
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function setSessionCookie(
|
||||
token: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionCookie(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function destroySessionFromRequest(): Promise<void> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
await clearSessionCookie();
|
||||
if (!token) return;
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
await prisma.session.deleteMany({ where: { tokenHash } });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
|
||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||
|
||||
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
||||
|
||||
const createFlowStepSchema = z.enum(flowStepTuple);
|
||||
|
||||
/**
|
||||
* Published rule `document` column: arbitrary JSON object with safety bounds.
|
||||
*/
|
||||
export const publishedRuleDocumentSchema = z
|
||||
.record(z.string(), z.unknown())
|
||||
.superRefine((doc, ctx) => {
|
||||
const err = assertPlainJsonValue(doc, 0, DEFAULT_PLAIN_JSON_LIMITS);
|
||||
if (err) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create-flow draft payload: known optional fields plus passthrough for future steps.
|
||||
* Full tree must satisfy {@link assertPlainJsonValue}.
|
||||
*/
|
||||
export const createFlowStateSchema = z
|
||||
.object({
|
||||
title: z.string().max(500).optional(),
|
||||
summary: z.string().max(8000).optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((data, ctx) => {
|
||||
const err = assertPlainJsonValue(data, 0, DEFAULT_PLAIN_JSON_LIMITS);
|
||||
if (err) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
|
||||
}
|
||||
});
|
||||
|
||||
export const publishRuleBodySchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.max(500)
|
||||
.transform((s) => s.trim())
|
||||
.refine((s) => s.length > 0, { message: "title required" }),
|
||||
summary: z
|
||||
.union([z.string().max(8000), z.null()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null) {
|
||||
return null;
|
||||
}
|
||||
const t = val.trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}),
|
||||
document: publishedRuleDocumentSchema,
|
||||
});
|
||||
|
||||
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
|
||||
|
||||
export const putDraftBodySchema = z.object({
|
||||
payload: createFlowStateSchema,
|
||||
});
|
||||
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Validates that a value is JSON-like (finite numbers, plain objects, no prototype tricks).
|
||||
* Used after JSON.parse for defense in depth against odd clients.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PLAIN_JSON_LIMITS = {
|
||||
maxDepth: 40,
|
||||
maxStringLength: 50_000,
|
||||
maxArrayLength: 5_000,
|
||||
maxObjectKeys: 500,
|
||||
} as const;
|
||||
|
||||
export type PlainJsonLimits = typeof DEFAULT_PLAIN_JSON_LIMITS;
|
||||
|
||||
/**
|
||||
* @returns `null` if valid, otherwise a short error message for API responses.
|
||||
*/
|
||||
export function assertPlainJsonValue(
|
||||
val: unknown,
|
||||
depth: number,
|
||||
limits: PlainJsonLimits = DEFAULT_PLAIN_JSON_LIMITS,
|
||||
): string | null {
|
||||
if (depth > limits.maxDepth) {
|
||||
return "Maximum nesting depth exceeded";
|
||||
}
|
||||
if (val === null) {
|
||||
return null;
|
||||
}
|
||||
const t = typeof val;
|
||||
if (t === "string") {
|
||||
const s = val as string;
|
||||
return s.length > limits.maxStringLength ? "String value too long" : null;
|
||||
}
|
||||
if (t === "number") {
|
||||
return Number.isFinite(val as number) ? null : "Invalid number value";
|
||||
}
|
||||
if (t === "boolean") {
|
||||
return null;
|
||||
}
|
||||
if (t === "bigint" || t === "function" || t === "symbol") {
|
||||
return "Invalid value type";
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length > limits.maxArrayLength) {
|
||||
return "Array too long";
|
||||
}
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const inner = assertPlainJsonValue(val[i], depth + 1, limits);
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (t === "object") {
|
||||
const o = val as Record<string, unknown>;
|
||||
const keys = Object.keys(o);
|
||||
if (keys.length > limits.maxObjectKeys) {
|
||||
return "Object has too many keys";
|
||||
}
|
||||
for (const k of keys) {
|
||||
if (k === "__proto__" || k === "constructor" || k === "prototype") {
|
||||
return "Unsafe object key";
|
||||
}
|
||||
const inner = assertPlainJsonValue(o[k], depth + 1, limits);
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return "Invalid value type";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const MAX_JSON_BODY_BYTES = 512 * 1024;
|
||||
|
||||
export type LimitedJsonResult =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* Read the body as text (bounded by maxBytes), then JSON.parse.
|
||||
* Returns 413 when over limit; 400 when JSON is invalid.
|
||||
*/
|
||||
export async function readLimitedJson(
|
||||
request: NextRequest,
|
||||
maxBytes: number = MAX_JSON_BODY_BYTES,
|
||||
): Promise<LimitedJsonResult> {
|
||||
const text = await request.text();
|
||||
if (text.length > maxBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "payload_too_large",
|
||||
message: `Request body must be at most ${maxBytes} bytes`,
|
||||
},
|
||||
},
|
||||
{ status: 413 },
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) as unknown };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "invalid_json",
|
||||
message: "Invalid JSON",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { ZodError } from "zod";
|
||||
|
||||
export function jsonFromZodError(error: ZodError): NextResponse {
|
||||
const issue = error.issues[0];
|
||||
const message = issue?.message ?? "Validation failed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "validation_error",
|
||||
message,
|
||||
},
|
||||
details: error.flatten(),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"placeholderTitle": "Your profile",
|
||||
"placeholderBody": "We’re building this space for your CommunityRules and account options. Check back soon."
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import createMDX from "@next/mdx";
|
||||
/* eslint-env node */
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["@prisma/client"],
|
||||
// Performance optimizations
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"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",
|
||||
"postinstall": "npm rebuild lightningcss && prisma generate",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:local": "storybook dev -p 6006",
|
||||
"storybook:github": "STORYBOOK_BASE_PATH=true storybook dev -p 6006",
|
||||
@@ -32,26 +33,30 @@
|
||||
"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",
|
||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||
"bundle:analyze": "node scripts/bundle-analyzer.js"
|
||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||
"db:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^16.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"ajv": "^8.12.0",
|
||||
"critters": "^0.0.23",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "^16.0.0",
|
||||
"next-intl": "^3.26.5",
|
||||
"nodemailer": "^8.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.2",
|
||||
@@ -67,6 +72,7 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "19.1.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
@@ -83,6 +89,7 @@
|
||||
"playwright": "^1.54.2",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"prisma": "^6.19.0",
|
||||
"start-server-and-test": "^2.0.13",
|
||||
"storybook": "^10.2.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -21,7 +21,8 @@ export default defineConfig({
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
workers: process.env.CI ? 2 : undefined, // Reduce workers in CI to prevent server overload
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3010",
|
||||
// Prefer 127.0.0.1 so it matches standalone server HOSTNAME and CI (wait-on tcp:127.0.0.1:3010).
|
||||
baseURL: process.env.BASE_URL || "http://127.0.0.1:3010",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
@@ -38,10 +39,11 @@ export default defineConfig({
|
||||
? {}
|
||||
: {
|
||||
webServer: {
|
||||
command: "npm run build && npx next start -p 3010",
|
||||
url: "http://localhost:3010",
|
||||
// `output: "standalone"` — use the standalone Node server (see Dockerfile), not `next start`.
|
||||
command: "npm run build && npm run start:e2e",
|
||||
url: "http://127.0.0.1:3010",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000, // Increased timeout to account for build time
|
||||
timeout: 240_000,
|
||||
},
|
||||
}),
|
||||
// Browser-specific snapshot path template (includes projectName for cross-browser support)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OtpChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RuleDraft" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RuleDraft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PublishedRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"summary" TEXT,
|
||||
"document" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PublishedRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RuleTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"description" TEXT,
|
||||
"body" JSONB NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "RuleTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OtpChallenge_email_idx" ON "OtpChallenge"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RuleDraft_userId_key" ON "RuleDraft"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PublishedRule_userId_idx" ON "PublishedRule"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RuleTemplate_slug_key" ON "RuleTemplate"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RuleDraft" ADD CONSTRAINT "RuleDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PublishedRule" ADD CONSTRAINT "PublishedRule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop legacy email OTP table (product uses magic link only).
|
||||
DROP TABLE IF EXISTS "OtpChallenge";
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "MagicLinkToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"nextPath" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MagicLinkToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MagicLinkToken_tokenHash_key" ON "MagicLinkToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MagicLinkToken_email_idx" ON "MagicLinkToken"("email");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,73 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
draft RuleDraft?
|
||||
rules PublishedRule[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model MagicLinkToken {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
nextPath String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model RuleDraft {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
payload Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PublishedRule {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
title String
|
||||
summary String?
|
||||
document Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model RuleTemplate {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
category String?
|
||||
description String?
|
||||
body Json
|
||||
sortOrder Int @default(0)
|
||||
featured Boolean @default(false)
|
||||
}
|
||||
@@ -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!'
|
||||
"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { Suspense, useEffect } from "react";
|
||||
import Login from "../../app/components/modals/Login";
|
||||
import LoginForm from "../../app/components/modals/Login/LoginForm";
|
||||
|
||||
/**
|
||||
* Storybook runs outside Next.js request context; successful "Send link" needs fetch mocked
|
||||
* because `requestMagicLink` POSTs to `/api/auth/magic-link/request`.
|
||||
*/
|
||||
function MagicLinkFetchMock({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (url.includes("/api/auth/magic-link/request")) {
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return orig(input as Request, init);
|
||||
};
|
||||
return () => {
|
||||
globalThis.fetch = orig;
|
||||
};
|
||||
}, []);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Components/Modals/Login",
|
||||
component: Login,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: "/login",
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Full-page style login shell (yellow backdrop) with modal card. Uses magic-link `LoginForm` inside. Matches `/login` and header modal usage.",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story: () => React.ReactNode) => (
|
||||
<div className="min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<MagicLinkFetchMock>
|
||||
<Story />
|
||||
</MagicLinkFetchMock>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export const ModalChromeOnly = {
|
||||
name: "Modal (placeholder content)",
|
||||
render: () => (
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
← Back to home
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<p
|
||||
id="login-modal-heading"
|
||||
className="font-inter px-2 py-4 text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
Placeholder body — use "With magic link form" for the real
|
||||
flow.
|
||||
</p>
|
||||
</Login>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithMagicLinkForm = {
|
||||
name: "With magic link form",
|
||||
render: () => (
|
||||
<Login
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={
|
||||
<a
|
||||
href="/"
|
||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
← Back to home
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<p className="font-inter p-6">Loading…</p>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</Login>
|
||||
),
|
||||
};
|
||||
|
||||
export const FormOnly = {
|
||||
name: "Login form (card inset)",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Form only, for inspecting copy and layout without the modal chrome. Wrap in `Login` in the app.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="mx-auto max-w-[560px] rounded-[20px] bg-[var(--color-surface-default-primary)] p-6 shadow-lg">
|
||||
<Suspense fallback={<p className="font-inter">Loading…</p>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import Login from "../../app/components/modals/Login";
|
||||
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders dialog when open and portal is ready", async () => {
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Login content</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Login content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dialog when closed", () => {
|
||||
renderWithProviders(
|
||||
<Login
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
>
|
||||
<p>Hidden</p>
|
||||
</Login>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Close dialog"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("locks body scroll while open", async () => {
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders belowCard outside the dialog card", async () => {
|
||||
renderWithProviders(
|
||||
<Login
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={<a href="/">Back to home</a>}
|
||||
>
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("link", { name: /back to home/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import LoginForm from "../../app/components/modals/Login/LoginForm";
|
||||
|
||||
const { navMock } = vi.hoisted(() => ({
|
||||
navMock: {
|
||||
searchParams: new URLSearchParams(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
replace: navMock.replace,
|
||||
push: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
}),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => navMock.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/create/api", () => ({
|
||||
requestMagicLink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { requestMagicLink } from "../../lib/create/api";
|
||||
|
||||
function renderLoginForm() {
|
||||
return renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requestMagicLink).mockReset();
|
||||
navMock.replace.mockReset();
|
||||
navMock.searchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
it("renders title, email field, and submit control", () => {
|
||||
renderLoginForm();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when email is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"not-an-email",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/enter a valid email address/i),
|
||||
).toBeInTheDocument();
|
||||
expect(requestMagicLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits trimmed email and shows success state when API succeeds", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
" Pat@Example.COM ",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith("pat@example.com", "/");
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: /check your email/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes safe next path when next query param is set", async () => {
|
||||
const user = userEvent.setup();
|
||||
navMock.searchParams = new URLSearchParams("next=/learn");
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"a@b.co",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith("a@b.co", "/learn");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows API error when request fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({
|
||||
ok: false,
|
||||
error: "Server says no",
|
||||
});
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"ok@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
expect(await screen.findByText("Server says no")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows rate limit message when retryAfterMs is present", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({
|
||||
ok: false,
|
||||
error: "Too many",
|
||||
retryAfterMs: 3500,
|
||||
});
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"ok@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/try again in 4 seconds/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows URL-driven error for expired_link", () => {
|
||||
navMock.searchParams = new URLSearchParams("error=expired_link");
|
||||
renderLoginForm();
|
||||
expect(
|
||||
screen.getByText(/that sign-in link has expired/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls router.replace to clear error query when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
navMock.searchParams = new URLSearchParams("error=expired_link");
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"x",
|
||||
);
|
||||
expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false });
|
||||
});
|
||||
|
||||
it("shows network error when request throws", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(requestMagicLink).mockRejectedValue(new Error("network"));
|
||||
renderLoginForm();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /email address/i }),
|
||||
"ok@example.com",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/check your connection/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 491 KiB |
|
Before Width: | Height: | Size: 882 KiB After Width: | Height: | Size: 903 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 557 KiB |
|
Before Width: | Height: | Size: 541 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 793 KiB |
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assertPlainJsonValue,
|
||||
DEFAULT_PLAIN_JSON_LIMITS,
|
||||
} from "../../lib/server/validation/plainJson";
|
||||
import {
|
||||
createFlowStateSchema,
|
||||
publishRuleBodySchema,
|
||||
putDraftBodySchema,
|
||||
} from "../../lib/server/validation/createFlowSchemas";
|
||||
|
||||
describe("assertPlainJsonValue", () => {
|
||||
it("accepts plain JSON structures", () => {
|
||||
expect(
|
||||
assertPlainJsonValue(
|
||||
{ a: [1, "x", { b: null }], c: true },
|
||||
0,
|
||||
DEFAULT_PLAIN_JSON_LIMITS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects __proto__ keys", () => {
|
||||
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||
"Unsafe object key",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-finite numbers", () => {
|
||||
expect(assertPlainJsonValue(Number.NaN, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||
"Invalid number value",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects excessive depth", () => {
|
||||
let v: unknown = 1;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
v = { x: v };
|
||||
}
|
||||
expect(assertPlainJsonValue(v, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||
"Maximum nesting depth exceeded",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFlowStateSchema", () => {
|
||||
it("accepts empty object", () => {
|
||||
const r = createFlowStateSchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts known fields and passthrough keys", () => {
|
||||
const r = createFlowStateSchema.safeParse({
|
||||
title: "My rule",
|
||||
currentStep: "cards",
|
||||
customField: { nested: [1, 2] },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid currentStep", () => {
|
||||
const r = createFlowStateSchema.safeParse({ currentStep: "not-a-step" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects title that is too long", () => {
|
||||
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putDraftBodySchema", () => {
|
||||
it("requires payload object", () => {
|
||||
expect(putDraftBodySchema.safeParse({}).success).toBe(false);
|
||||
expect(putDraftBodySchema.safeParse({ payload: {} }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishRuleBodySchema", () => {
|
||||
it("accepts minimal valid body", () => {
|
||||
const r = publishRuleBodySchema.safeParse({
|
||||
title: " Hello ",
|
||||
document: { body: "text" },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.title).toBe("Hello");
|
||||
expect(r.data.summary).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("trims summary and maps empty to null", () => {
|
||||
const r = publishRuleBodySchema.safeParse({
|
||||
title: "T",
|
||||
summary: " ",
|
||||
document: {},
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.summary).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty title", () => {
|
||||
const r = publishRuleBodySchema.safeParse({
|
||||
title: " ",
|
||||
document: {},
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-object document", () => {
|
||||
const r = publishRuleBodySchema.safeParse({
|
||||
title: "Ok",
|
||||
document: "nope",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||