Prepare Portainer deployment

This commit is contained in:
Zachariah K. Sharma
2026-06-05 23:46:10 -06:00
commit e6f1c5c13f
84 changed files with 12383 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "@/lib/auth-handlers";
+47
View File
@@ -0,0 +1,47 @@
// Download for an UploadedFile.
//
// Authorization rules:
// - Branding images (referenced as form.settings.coverImage / .logo) are public —
// the form filler needs to see them.
// - Files attached to a response: viewer/owner/admin only.
// - Pre-submit orphan uploads: only the uploader sees them.
import { type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseSettings } from "@/lib/forms";
import { driver, type StorageDriver } from "@/lib/storage";
export const runtime = "nodejs";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const file = await prisma.uploadedFile.findUnique({
where: { id },
include: { form: { select: { settings: true, ownerId: true } } },
});
if (!file) return new Response("Not found", { status: 404 });
// Branding images are public.
const s = parseSettings(file.form.settings);
const isBranding = s.coverImage === file.id || s.logo === file.id;
if (!isBranding) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const isUploader = file.uploaderId === session.user.id;
const canView = isUploader || (await canViewResults(file.formId, session.user.id, session.user.role));
if (!canView) return new Response("Forbidden", { status: 403 });
}
const stream = await driver(file.driver as StorageDriver).read(file.storageKey);
const download = req.nextUrl.searchParams.get("download") === "1";
return new Response(stream as unknown as ReadableStream, {
headers: {
"content-type": file.contentType || "application/octet-stream",
"content-length": String(file.size),
"content-disposition": `${download ? "attachment" : "inline"}; filename="${file.name.replace(/"/g, "")}"`,
"cache-control": "private, max-age=0, must-revalidate",
},
});
}
+83
View File
@@ -0,0 +1,83 @@
// Multipart upload endpoint. Used by the file field in FormRuntime.
// Accepts:
// form-data field "file" — the file blob
// form-data field "formId" — the form this file is being uploaded for
//
// Returns: { id, name, size, contentType } — the client stores this as the
// field value. The file is associated with a response when the response is
// submitted (UploadedFile.responseId is set then).
import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import { driver, currentDriver } from "@/lib/storage";
import { consume, clientIp } from "@/lib/ratelimit";
const MAX_DEFAULT_MB = 25;
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
// Rate-limit uploads per IP.
const ip = clientIp(req.headers);
const rl = await consume(`upload:${ip}`, 20, 60_000);
if (!rl.ok) {
return NextResponse.json(
{ error: "Too many uploads. Slow down." },
{ status: 429, headers: { "retry-after": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } },
);
}
const fd = await req.formData().catch(() => null);
if (!fd) return NextResponse.json({ error: "Multipart body required" }, { status: 400 });
const file = fd.get("file");
const formId = String(fd.get("formId") ?? "");
if (!(file instanceof File) || !formId) {
return NextResponse.json({ error: "Missing file or formId" }, { status: 400 });
}
const form = await prisma.form.findUnique({ where: { id: formId } });
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
const fields = parseFields(form.fields);
const session = await auth();
const isOwnerOrAdmin = !!session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
// Public form fillers can only upload to published forms; owners/admins can
// upload to draft forms too (branding images uploaded during editing).
if (!form.published && !isOwnerOrAdmin) return NextResponse.json({ error: "Not found" }, { status: 404 });
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Enforce per-form max size (cap by the most permissive file field, or default).
const maxMB = Math.max(MAX_DEFAULT_MB, ...fields.filter((f) => f.type === "file").map((f) => f.maxSizeMB ?? MAX_DEFAULT_MB));
if (file.size > maxMB * 1024 * 1024) {
return NextResponse.json({ error: `File too large (max ${maxMB} MB)` }, { status: 413 });
}
const buf = Buffer.from(await file.arrayBuffer());
const id = nanoid(16);
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload";
const key = `${form.id}/${id}-${safeName}`;
const drv = currentDriver();
await driver(drv).put(key, buf, { size: file.size, contentType: file.type || "application/octet-stream" });
const row = await prisma.uploadedFile.create({
data: {
formId: form.id,
uploaderId: session?.user?.id ?? null,
storageKey: key,
driver: drv,
name: file.name || safeName,
size: file.size,
contentType: file.type || "application/octet-stream",
},
select: { id: true, name: true, size: true, contentType: true },
});
return NextResponse.json(row);
}
+30
View File
@@ -0,0 +1,30 @@
// Analytics beacon — extremely lightweight POST. Accepts: { kind, sessionKey, durationMs? }.
// Called via navigator.sendBeacon() from the runtime. Always returns 204.
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { consume, clientIp } from "@/lib/ratelimit";
const VALID = new Set(["view", "start", "submit", "abandon"]);
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const rl = await consume(`event:${id}:${clientIp(req.headers)}`, 60, 60_000);
if (!rl.ok) return new Response(null, { status: 204 });
// sendBeacon sends a Blob, not always JSON — accept either.
let body: { kind?: string; sessionKey?: string; durationMs?: number } = {};
try { body = await req.json(); } catch { /* ignore */ }
if (!body.kind || !VALID.has(body.kind) || !body.sessionKey) return new Response(null, { status: 204 });
await prisma.formEvent.create({
data: {
formId: id,
kind: body.kind,
sessionKey: String(body.sessionKey).slice(0, 64),
durationMs: typeof body.durationMs === "number" ? Math.max(0, Math.min(86_400_000, body.durationMs)) : null,
},
}).catch(() => {});
return new Response(null, { status: 204 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseFields } from "@/lib/forms";
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) return new NextResponse("Unauthorized", { status: 401 });
if (!(await canViewResults(id, session.user.id, session.user.role))) {
return new NextResponse("Forbidden", { status: 403 });
}
const form = await prisma.form.findUnique({ where: { id } });
if (!form) return new NextResponse("Not found", { status: 404 });
const fields = parseFields(form.fields);
const responses = await prisma.response.findMany({
where: { formId: id },
orderBy: { createdAt: "asc" },
include: { submitter: { select: { email: true } } },
});
const header = ["created_at", "submitter", ...fields.map((f) => f.label)];
const rows = responses.map((r) => {
let data: Record<string, unknown> = {};
try { data = JSON.parse(r.data); } catch {}
return [
r.createdAt.toISOString(),
r.submitter?.email ?? "",
...fields.map((f) => stringify(data[f.id])),
];
});
const csv = [header, ...rows].map((r) => r.map(csvCell).join(",")).join("\n");
return new NextResponse(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${form.slug}.csv"`,
},
});
}
function stringify(v: unknown): string {
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join("; ");
if (typeof v === "boolean") return v ? "true" : "false";
return String(v);
}
function csvCell(s: string) {
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
+82
View File
@@ -0,0 +1,82 @@
// Partial submission persistence. Lets responders close the tab and come back.
//
// - Signed-in users: keyed by (formId, submitterId).
// - Anonymous: an httpOnly cookie "fb_partial=<nanoid>" identifies the row.
// We set it on first PUT/GET if missing.
//
// GET → { data: Record<string, unknown> } | null
// PUT → { ok: true } body: { data: Record<string, unknown> }
//
// Submissions delete the partial row in the main submit handler.
import { NextResponse, type NextRequest } from "next/server";
import { nanoid } from "nanoid";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseSettings } from "@/lib/forms";
const COOKIE = "fb_partial";
async function getKey(req: NextRequest): Promise<{ submitterId: string | null; anonKey: string | null; setCookie?: string }> {
const session = await auth();
if (session?.user) return { submitterId: session.user.id, anonKey: null };
const existing = req.cookies.get(COOKIE)?.value;
if (existing) return { submitterId: null, anonKey: existing };
const fresh = nanoid(24);
return { submitterId: null, anonKey: fresh, setCookie: fresh };
}
function cookieHeader(value: string): string {
return `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const form = await prisma.form.findUnique({ where: { id } });
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
const k = await getKey(req);
const where = k.submitterId
? { formId_submitterId: { formId: id, submitterId: k.submitterId } }
: { formId_anonKey: { formId: id, anonKey: k.anonKey! } };
const row = await prisma.partialResponse.findUnique({ where });
const res = NextResponse.json(row ? { data: safeParse(row.data) } : null);
if (k.setCookie) res.headers.append("set-cookie", cookieHeader(k.setCookie));
return res;
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const form = await prisma.form.findUnique({ where: { id } });
if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
if ((settings.visibility ?? "workspace") === "workspace") {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const data = body.data;
if (!data || typeof data !== "object") return NextResponse.json({ error: "Bad request" }, { status: 400 });
const k = await getKey(req);
const json = JSON.stringify(data);
await prisma.partialResponse.upsert({
where: k.submitterId
? { formId_submitterId: { formId: id, submitterId: k.submitterId } }
: { formId_anonKey: { formId: id, anonKey: k.anonKey! } },
update: { data: json },
create: { formId: id, submitterId: k.submitterId, anonKey: k.anonKey, data: json },
});
const res = NextResponse.json({ ok: true });
if (k.setCookie) res.headers.append("set-cookie", cookieHeader(k.setCookie));
return res;
}
function safeParse(s: string): unknown {
try { return JSON.parse(s); } catch { return {}; }
}
+154
View File
@@ -0,0 +1,154 @@
import { NextResponse, type NextRequest } from "next/server";
import { after } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import { visibleFields } from "@/lib/logic";
import { applyCalculations } from "@/lib/calc";
import { consume, clientIp } from "@/lib/ratelimit";
import { sendEmail } from "@/lib/notify";
import { enqueueWebhook } from "@/lib/webhook";
import { escapeHtml } from "@/lib/sanitize";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const ip = clientIp(req.headers);
const rl = await consume(`submit:${id}:${ip}`, 10, 60_000);
if (!rl.ok) {
const retry = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: "Too many submissions. Slow down." },
{ status: 429, headers: { "retry-after": String(retry) } },
);
}
const form = await prisma.form.findUnique({ where: { id } });
if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 });
const settings = parseSettings(form.settings);
const session = await auth();
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
// Honeypot — bots fill the hidden _h field; humans don't.
if (typeof body._h === "string" && body._h.length > 0) {
// Pretend success so bots don't learn we noticed.
return NextResponse.json({ ok: true });
}
// hCaptcha verification (only when enabled on the form and a secret is configured).
if (settings.hcaptchaEnabled && process.env.HCAPTCHA_SECRET) {
const token = typeof body._captcha === "string" ? body._captcha : "";
if (!token) return NextResponse.json({ error: "Captcha required" }, { status: 400 });
const verify = await fetch("https://api.hcaptcha.com/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: process.env.HCAPTCHA_SECRET, response: token }),
}).then((r) => r.json() as Promise<{ success: boolean }>).catch(() => ({ success: false }));
if (!verify.success) return NextResponse.json({ error: "Captcha failed" }, { status: 400 });
}
const data = (body.data ?? {}) as Record<string, unknown>;
const fields = parseFields(form.fields);
const shown = visibleFields(fields, data);
// Server-side validation (mirrors client).
const cleaned: Record<string, unknown> = {};
for (const f of shown) {
const v = data[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) return NextResponse.json({ error: `Missing: ${f.label}` }, { status: 400 });
if (!empty) {
if (f.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))) {
return NextResponse.json({ error: `Invalid email: ${f.label}` }, { status: 400 });
}
if (f.type === "number") {
const n = Number(v);
if (Number.isNaN(n)) return NextResponse.json({ error: `Not a number: ${f.label}` }, { status: 400 });
if (f.min !== undefined && n < f.min) return NextResponse.json({ error: `${f.label} < ${f.min}` }, { status: 400 });
if (f.max !== undefined && n > f.max) return NextResponse.json({ error: `${f.label} > ${f.max}` }, { status: 400 });
cleaned[f.id] = n;
continue;
}
cleaned[f.id] = v;
}
}
// Compute calculated fields server-side (don't trust client values).
const withCalcs = applyCalculations(fields, cleaned);
const response = await prisma.response.create({
data: {
formId: form.id,
submitterId: session?.user?.id ?? null,
data: JSON.stringify(withCalcs),
},
});
// Bind any uploaded files to this response so permission checks work later
// and orphan-cleanup can prune un-attached uploads.
const fileIds: string[] = [];
for (const f of fields) {
if (f.type !== "file") continue;
const v = cleaned[f.id] as { id?: string } | undefined;
if (v && typeof v.id === "string") fileIds.push(v.id);
}
if (fileIds.length > 0) {
await prisma.uploadedFile.updateMany({
where: { id: { in: fileIds }, formId: form.id },
data: { responseId: response.id },
}).catch(() => {});
}
// Clear the partial row (if any) — submission complete.
const partialKey = session?.user
? { formId_submitterId: { formId: form.id, submitterId: session.user.id } }
: (req.cookies.get("fb_partial")?.value
? { formId_anonKey: { formId: form.id, anonKey: req.cookies.get("fb_partial")!.value } }
: null);
if (partialKey) {
await prisma.partialResponse.delete({ where: partialKey }).catch(() => {});
}
// Notifications fire after the response is returned — never block the user.
after(async () => {
const baseUrl = process.env.PUBLIC_BASE_URL ?? "";
if (settings.notifyEmails && settings.notifyEmails.length > 0) {
const lines = fields
.filter((f) => f.type !== "page_break" && withCalcs[f.id] !== undefined)
.map((f) => `<p><strong>${escapeHtml(f.label)}</strong><br>${escapeHtml(String(formatVal(withCalcs[f.id])))}</p>`)
.join("");
await sendEmail({
to: settings.notifyEmails,
subject: `New response: ${form.title}`,
html: `<h2>${escapeHtml(form.title)}</h2>${lines}<p><a href="${baseUrl}/app/forms/${form.id}/responses">View all responses</a></p>`,
});
}
if (settings.webhookUrl) {
await enqueueWebhook({
url: settings.webhookUrl,
secret: settings.webhookSecret,
payload: {
event: "response.created",
formId: form.id,
responseId: response.id,
data: withCalcs,
submittedAt: response.createdAt.toISOString(),
},
});
}
});
return NextResponse.json({ ok: true });
}
function formatVal(v: unknown): string {
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "boolean") return v ? "Yes" : "No";
return String(v ?? "");
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import { authenticate, handleRpc } from "@/lib/mcp";
import { consume } from "@/lib/ratelimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const ctx = await authenticate(req.headers.get("authorization"));
if (!ctx) {
return new NextResponse(JSON.stringify({
jsonrpc: "2.0", id: null,
error: { code: -32001, message: "Unauthorized — send Authorization: Bearer <token>" },
}), { status: 401, headers: { "content-type": "application/json", "WWW-Authenticate": "Bearer" } });
}
const rl = await consume(`mcp:${ctx.userId}`, 60, 60_000);
if (!rl.ok) {
const retry = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return new NextResponse(JSON.stringify({
jsonrpc: "2.0", id: null,
error: { code: -32002, message: "Rate limit exceeded" },
}), { status: 429, headers: { "content-type": "application/json", "retry-after": String(retry) } });
}
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, { status: 400 });
// Support both single and batch requests
if (Array.isArray(body)) {
const out = (await Promise.all(body.map((m) => handleRpc(m, ctx)))).filter(Boolean);
return NextResponse.json(out);
}
const res = await handleRpc(body, ctx);
if (!res) return new NextResponse(null, { status: 204 });
return NextResponse.json(res);
}
export async function GET() {
return NextResponse.json({
name: "formbuilder MCP",
transport: "http",
instructions: "POST JSON-RPC 2.0 messages here with Authorization: Bearer <token>.",
});
}
+33
View File
@@ -0,0 +1,33 @@
// Admin/owner retry-now endpoint. POSTs to /api/webhooks/<deliveryId>/retry to
// force an immediate attempt, regardless of nextAttemptAt.
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm } from "@/lib/forms";
import { attemptDelivery } from "@/lib/webhook";
import { recordAudit } from "@/lib/audit";
export const runtime = "nodejs";
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const row = await prisma.webhookDelivery.findUnique({ where: { id }, select: { formId: true } });
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!(await canEditForm(row.formId, session.user.id, session.user.role))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ok = await attemptDelivery(id);
await recordAudit({
actorId: session.user.id,
action: "webhook.retried",
entityType: "webhook",
entityId: id,
metadata: { ok },
});
return NextResponse.json({ ok });
}
+25
View File
@@ -0,0 +1,25 @@
// Cron-driven webhook drainer. Call this every minute from an external scheduler
// (cron-job.org, a Kubernetes CronJob, or `node -e "fetch(...)"` on a timer).
//
// Auth: set CRON_SECRET in env, then call with header `Authorization: Bearer <secret>`.
import { NextResponse, type NextRequest } from "next/server";
import { processDueWebhooks } from "@/lib/webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) return NextResponse.json({ error: "CRON_SECRET not configured" }, { status: 503 });
const auth = req.headers.get("authorization") ?? "";
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const res = await processDueWebhooks();
return NextResponse.json(res);
}
export async function GET(req: NextRequest) {
return POST(req);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { createToken, revokeToken } from "./tokenActions";
import { Trash2 } from "lucide-react";
type Token = { id: string; name: string; prefix: string; createdAt: string; lastUsedAt: string | null };
export default function TokenManager({ initialTokens }: { initialTokens: Token[] }) {
const [tokens, setTokens] = useState<Token[]>(initialTokens);
const [name, setName] = useState("");
const [secret, setSecret] = useState<string | null>(null);
const [pending, start] = useTransition();
return (
<div className="space-y-3">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!name.trim()) return;
start(async () => {
const r = await createToken(name.trim());
setSecret(r.secret);
setTokens([{ id: r.id, name: r.name, prefix: r.prefix, createdAt: new Date().toISOString(), lastUsedAt: null }, ...tokens]);
setName("");
});
}}
>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Token name (e.g. Claude Desktop)" />
<Button type="submit" variant="primary" disabled={pending}>Create</Button>
</form>
{secret && (
<div className="border border-fg/30 rounded-lg p-3 bg-line/30 space-y-1.5">
<div className="text-xs text-muted">Copy now you won't see it again.</div>
<div className="flex gap-2">
<code className="font-mono text-sm flex-1 break-all">{secret}</code>
<Button size="sm" variant="outline" onClick={() => navigator.clipboard.writeText(secret)}>Copy</Button>
<Button size="sm" variant="ghost" onClick={() => setSecret(null)}>Dismiss</Button>
</div>
</div>
)}
<ul className="border border-line rounded-xl divide-y divide-line">
{tokens.length === 0 && (
<li className="px-4 py-6 text-sm text-muted text-center">No tokens.</li>
)}
{tokens.map((t) => (
<li key={t.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<div>{t.name}</div>
<div className="text-xs text-muted font-mono">{t.prefix} · created {new Date(t.createdAt).toLocaleDateString()}</div>
</div>
<button
onClick={() => start(async () => { await revokeToken(t.id); setTokens(tokens.filter((x) => x.id !== t.id)); })}
className="btn btn-ghost btn-sm text-muted hover:text-danger"
aria-label="Revoke"
>
<Trash2 size={14}/>
</button>
</li>
))}
</ul>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import TokenManager from "./TokenManager";
export default async function AccountPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
const tokens = await prisma.accessToken.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
select: { id: true, name: true, prefix: true, createdAt: true, lastUsedAt: true },
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-xl font-semibold mb-1">Account</h1>
<p className="text-sm text-muted">{session.user.email} · {session.user.role}</p>
</div>
<section className="space-y-3">
<div>
<h2 className="font-medium">MCP tokens</h2>
<p className="text-xs text-muted">
Use these to connect an LLM client to <code className="font-mono">/api/mcp</code>.
Send <code className="font-mono">Authorization: Bearer &lt;token&gt;</code>.
</p>
</div>
<TokenManager initialTokens={tokens.map((t) => ({ ...t, createdAt: t.createdAt.toISOString(), lastUsedAt: t.lastUsedAt?.toISOString() ?? null }))} />
</section>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { createHash, randomBytes } from "crypto";
export async function createToken(name: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const secret = `fb_${randomBytes(24).toString("hex")}`;
const tokenHash = createHash("sha256").update(secret).digest("hex");
const prefix = secret.slice(0, 10);
const t = await prisma.accessToken.create({
data: { userId: session.user.id, name, tokenHash, prefix },
select: { id: true, name: true, prefix: true },
});
revalidatePath("/app/account");
return { ...t, secret };
}
export async function revokeToken(id: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await prisma.accessToken.deleteMany({ where: { id, userId: session.user.id } });
revalidatePath("/app/account");
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseAuditMeta } from "@/lib/audit";
import { ScrollText } from "lucide-react";
function fmtRelative(d: Date): string {
const ms = Date.now() - d.getTime();
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
const VERBS: Record<string, (m: Record<string, unknown>) => string> = {
"form.created": (m) => `created form “${m.title ?? "Untitled"}`,
"form.updated": () => "edited a form",
"form.deleted": (m) => `deleted form “${m.title ?? "Untitled"}`,
"form.published": (m) => `published “${m.title ?? "Untitled"}`,
"form.unpublished": (m) => `unpublished “${m.title ?? "Untitled"}`,
"form.reverted": (m) => `reverted to v${m.revertedTo}`,
"form.viewers_set": (m) => `updated viewers (${m.count})`,
"form.tagged": (m) => `set tags: ${(m.tags as string[] | undefined)?.join(", ") || "none"}`,
"response.deleted": () => "deleted a response",
"member.role_changed": (m) => `${m.email}: ${m.from}${m.to}`,
"member.removed": (m) => `removed ${m.email}`,
"token.created": (m) => `created API token “${m.name}`,
"token.revoked": (m) => `revoked API token`,
"webhook.retried": (m) => `retried webhook (${m.ok ? "ok" : "failed"})`,
};
export default async function AuditPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
if (session.user.role !== "admin") redirect("/app");
const rows = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 200,
include: { actor: { select: { name: true, email: true } } },
});
return (
<div className="space-y-4 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Audit log</h1>
<p className="text-sm text-[rgb(var(--muted))]">Last 200 events. Append-only.</p>
</div>
<ScrollText size={18} className="text-[rgb(var(--muted))]" />
</div>
{rows.length === 0 ? (
<div className="card p-10 text-center text-sm text-[rgb(var(--muted))]">
Nothing logged yet.
</div>
) : (
<ul className="border border-[rgb(var(--line))] rounded-xl divide-y divide-[rgb(var(--line))]">
{rows.map((r) => {
const meta = parseAuditMeta(r.metadata);
const fmt = VERBS[r.action] ?? (() => r.action);
const actor = r.actor?.name || r.actor?.email || "system";
const detail = fmt(meta);
const formLink =
r.entityType === "form" && r.entityId && r.action !== "form.deleted"
? `/app/forms/${r.entityId}`
: null;
return (
<li key={r.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<span className="font-medium text-[rgb(var(--fg))]">{actor}</span>{" "}
<span className="text-[rgb(var(--muted))]">{detail}</span>
{formLink && (
<Link href={formLink} className="ml-2 text-xs text-[rgb(var(--muted))] underline">
open
</Link>
)}
</div>
<div className="text-xs text-[rgb(var(--muted))] shrink-0 font-mono">
{fmtRelative(r.createdAt)}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults } from "@/lib/forms";
import { ArrowLeft } from "lucide-react";
const DAYS = 30;
export default async function Analytics({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id } });
if (!form) notFound();
if (!(await canViewResults(form.id, session.user.id, session.user.role))) {
return <p className="text-sm text-muted">You don't have access to this form's analytics.</p>;
}
const since = new Date(Date.now() - DAYS * 86_400_000);
const events = await prisma.formEvent.groupBy({
by: ["kind"],
where: { formId: id, createdAt: { gte: since } },
_count: { _all: true },
});
const totals = Object.fromEntries(events.map((e) => [e.kind, e._count._all])) as Record<string, number>;
const views = totals.view ?? 0;
const starts = totals.start ?? 0;
const submits = totals.submit ?? 0;
const completion = starts > 0 ? Math.round((submits / starts) * 100) : 0;
const submitDurations = await prisma.formEvent.findMany({
where: { formId: id, kind: "submit", durationMs: { not: null }, createdAt: { gte: since } },
select: { durationMs: true },
take: 500,
});
const avgMs = submitDurations.length > 0
? submitDurations.reduce((s, e) => s + (e.durationMs ?? 0), 0) / submitDurations.length
: 0;
// Daily counts by kind for sparkline.
type Row = { day: Date; kind: string; n: bigint };
const daily = await prisma.$queryRaw<Row[]>`
SELECT date_trunc('day', "createdAt") AS day, kind, COUNT(*)::bigint AS n
FROM "FormEvent"
WHERE "formId" = ${id} AND "createdAt" >= ${since}
GROUP BY day, kind
ORDER BY day ASC
`;
const buckets = bucketize(daily, since, DAYS);
return (
<div className="space-y-6">
<div className="flex items-center gap-3 flex-wrap">
<Link href={`/app/forms/${form.id}`} className="btn btn-ghost btn-sm"><ArrowLeft size={14}/>{form.title}</Link>
<h1 className="text-lg font-semibold">Analytics</h1>
<span className="text-xs text-muted">last {DAYS} days</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card label="Views" value={views} />
<Card label="Starts" value={starts} />
<Card label="Submits" value={submits} />
<Card label="Completion" value={`${completion}%`} sub={`avg ${formatDuration(avgMs)}`} />
</div>
<div className="border border-line rounded-xl p-4 space-y-3">
<div className="text-xs uppercase tracking-wide text-muted">Daily</div>
<Sparkline series={buckets.view} color="rgb(var(--muted))" label="Views" />
<Sparkline series={buckets.submit} color="rgb(var(--fg))" label="Submits" />
</div>
</div>
);
}
function Card({ label, value, sub }: { label: string; value: number | string; sub?: string }) {
return (
<div className="border border-line rounded-xl p-4">
<div className="text-xs text-muted">{label}</div>
<div className="text-2xl font-semibold mt-1">{value}</div>
{sub && <div className="text-xs text-muted mt-1">{sub}</div>}
</div>
);
}
function Sparkline({ series, color, label }: { series: number[]; color: string; label: string }) {
const max = Math.max(1, ...series);
const w = 600, h = 40;
const stepX = w / Math.max(1, series.length - 1);
const points = series.map((v, i) => `${(i * stepX).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`).join(" ");
return (
<div>
<div className="text-xs text-muted mb-1">{label}</div>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-10" preserveAspectRatio="none">
<polyline points={points} fill="none" stroke={color} strokeWidth={1.5} />
</svg>
</div>
);
}
type Row = { day: Date; kind: string; n: bigint };
function bucketize(rows: Row[], since: Date, days: number) {
const start = new Date(since); start.setUTCHours(0, 0, 0, 0);
const view = new Array(days).fill(0) as number[];
const submit = new Array(days).fill(0) as number[];
for (const r of rows) {
const idx = Math.floor((new Date(r.day).getTime() - start.getTime()) / 86_400_000);
if (idx < 0 || idx >= days) continue;
const n = Number(r.n);
if (r.kind === "view") view[idx] += n;
if (r.kind === "submit") submit[idx] += n;
}
return { view, submit };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
return `${Math.round(s / 60)} min`;
}
+54
View File
@@ -0,0 +1,54 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm, parseFields, parseSettings } from "@/lib/forms";
import { listAllTags } from "@/lib/tags";
import Builder from "@/components/builder/Builder";
import TagBar from "@/components/ui/TagBar";
export default async function FormPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({
where: { id },
include: {
viewers: { select: { userId: true } },
tags: { include: { tag: { select: { slug: true, name: true } } } },
},
});
if (!form) notFound();
if (!(await canEditForm(form.id, session.user.id, session.user.role))) {
redirect(`/app/forms/${form.id}/responses`);
}
const [members, allTags] = await Promise.all([
prisma.user.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, email: true },
}),
listAllTags(),
]);
return (
<div className="space-y-3">
<TagBar
formId={form.id}
initialTags={form.tags.map((ft) => ({ slug: ft.tag.slug, name: ft.tag.name }))}
allTags={allTags.map((t) => ({ slug: t.slug, name: t.name, count: t.count }))}
/>
<Builder
form={{
id: form.id,
slug: form.slug,
title: form.title,
description: form.description ?? "",
published: form.published,
fields: parseFields(form.fields),
settings: parseSettings(form.settings),
viewerIds: form.viewers.map((v) => v.userId),
}}
members={members}
currentUserId={session.user.id}
/>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canViewResults, parseFields } from "@/lib/forms";
import { Download, ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
const PAGE_SIZE = 50;
export default async function Responses({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const { id } = await params;
const sp = await searchParams;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id } });
if (!form) notFound();
if (!(await canViewResults(form.id, session.user.id, session.user.role))) {
return <p className="text-sm text-muted">You don't have access to these responses.</p>;
}
const fields = parseFields(form.fields);
const total = await prisma.response.count({ where: { formId: id } });
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(Math.max(1, Number(sp.page) || 1), pages);
const responses = await prisma.response.findMany({
where: { formId: id },
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
include: { submitter: { select: { name: true, email: true } } },
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<Link href={`/app/forms/${form.id}`} className="btn btn-ghost btn-sm"><ArrowLeft size={14}/>{form.title}</Link>
<h1 className="text-lg font-semibold">Responses</h1>
<span className="text-xs text-muted">{total} total</span>
<Link href={`/api/forms/${form.id}/export`} className="ml-auto btn btn-outline btn-sm">
<Download size={14}/>CSV
</Link>
</div>
{responses.length === 0 ? (
<div className="border border-dashed border-line rounded-xl p-10 text-center text-muted text-sm">No responses yet.</div>
) : (
<div className="border border-line rounded-xl overflow-auto">
<table className="w-full text-sm">
<thead className="bg-line/30 text-xs uppercase tracking-wide text-muted">
<tr>
<th className="text-left px-3 py-2 font-medium">When</th>
<th className="text-left px-3 py-2 font-medium">By</th>
{fields.map((f) => <th key={f.id} className="text-left px-3 py-2 font-medium">{f.label}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{responses.map((r) => {
let data: Record<string, unknown> = {};
try { data = JSON.parse(r.data); } catch {}
return (
<tr key={r.id}>
<td className="px-3 py-2 text-muted whitespace-nowrap">{r.createdAt.toLocaleString()}</td>
<td className="px-3 py-2 text-muted whitespace-nowrap">{r.submitter?.name || r.submitter?.email || "anonymous"}</td>
{fields.map((f) => (
<td key={f.id} className="px-3 py-2 max-w-xs truncate">{renderCell(f.type, data[f.id])}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div className="flex items-center justify-end gap-1 text-sm">
<Link
href={`/app/forms/${form.id}/responses?page=${Math.max(1, page - 1)}`}
aria-disabled={page === 1}
className={`btn btn-ghost btn-sm ${page === 1 ? "opacity-40 pointer-events-none" : ""}`}
>
<ChevronLeft size={14}/>Prev
</Link>
<span className="text-xs text-muted px-2">Page {page} / {pages}</span>
<Link
href={`/app/forms/${form.id}/responses?page=${Math.min(pages, page + 1)}`}
aria-disabled={page === pages}
className={`btn btn-ghost btn-sm ${page === pages ? "opacity-40 pointer-events-none" : ""}`}
>
Next<ChevronRight size={14}/>
</Link>
</div>
)}
</div>
);
}
function formatCell(v: unknown): string {
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "boolean") return v ? "✓" : "";
return String(v);
}
function renderCell(type: string, v: unknown) {
if ((type === "file" || type === "signature") && v && typeof v === "object" && "id" in (v as object)) {
const f = v as { id: string; name: string };
if (type === "signature") {
// eslint-disable-next-line @next/next/no-img-element
return <a href={`/api/files/${f.id}`} target="_blank" rel="noreferrer"><img src={`/api/files/${f.id}`} alt={f.name} className="h-10 inline-block" /></a>;
}
return (
<a href={`/api/files/${f.id}`} target="_blank" rel="noreferrer" className="underline text-fg hover:opacity-80">
{f.name}
</a>
);
}
return formatCell(v);
}
+69
View File
@@ -0,0 +1,69 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { canEditForm } from "@/lib/forms";
import { listVersions } from "@/lib/versions";
import { revertFormToVersion } from "@/lib/actions";
import { History, RotateCcw, ArrowLeft } from "lucide-react";
function fmtRelative(d: Date): string {
const ms = Date.now() - d.getTime();
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export default async function VersionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/signin");
const form = await prisma.form.findUnique({ where: { id }, select: { id: true, title: true } });
if (!form) notFound();
if (!(await canEditForm(form.id, session.user.id, session.user.role))) {
redirect(`/app/forms/${form.id}/responses`);
}
const versions = await listVersions(form.id);
return (
<div className="space-y-5 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<Link href={`/app/forms/${form.id}`} className="text-xs text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))] inline-flex items-center gap-1 mb-1">
<ArrowLeft size={12} /> Back to builder
</Link>
<h1 className="text-xl font-semibold tracking-tight">{form.title || "Untitled form"} · history</h1>
<p className="text-sm text-[rgb(var(--muted))]">
{versions.length} version{versions.length === 1 ? "" : "s"}. The latest is at the top.
</p>
</div>
<History size={18} className="text-[rgb(var(--muted))]" />
</div>
<ul className="border border-[rgb(var(--line))] rounded-xl divide-y divide-[rgb(var(--line))]">
{versions.map((v, idx) => (
<li key={v.id} className="flex items-center gap-4 px-4 py-3">
<div className="text-xs font-mono text-[rgb(var(--muted))] w-12">v{v.version}</div>
<div className="min-w-0 flex-1">
<div className="text-sm">{v.title || "Untitled"}</div>
<div className="text-xs text-[rgb(var(--muted))]">
{v.author?.name || v.author?.email || "system"} · {fmtRelative(v.createdAt)}
{idx === 0 && <span className="ml-2 text-[rgb(var(--fg))]">· current</span>}
</div>
</div>
{idx !== 0 && (
<form action={async () => { "use server"; await revertFormToVersion(form.id, v.version); }}>
<button className="btn btn-outline btn-sm" type="submit">
<RotateCcw size={12} />
Revert
</button>
</form>
)}
</li>
))}
</ul>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Link from "next/link";
import { auth, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { NavLinks } from "@/components/ui/NavLinks";
import { LogOut } from "lucide-react";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/signin");
const isAdmin = session.user.role === "admin";
const initials = (session.user.name || session.user.email || "U")
.split(" ").map((p: string) => p[0]).join("").toUpperCase().slice(0, 2);
return (
<div className="min-h-screen bg-[rgb(var(--bg))]">
{/* Top header bar */}
<header className="sticky top-0 z-40 bg-[rgb(var(--surface)/0.95)] border-b border-[rgb(var(--line))]"
style={{ backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)" }}>
<div className="max-w-6xl mx-auto px-5 h-14 flex items-center gap-3">
{/* Logo */}
<Link href="/app" className="shrink-0 text-[rgb(var(--fg))] hover:opacity-60 transition-opacity mr-1">
<AsteriskLogo />
</Link>
{/* Vertical separator */}
<div className="w-px h-4 bg-[rgb(var(--line))] shrink-0" />
{/* Nav links */}
<NavLinks isAdmin={isAdmin} />
{/* Right: user + controls */}
<div className="ml-auto flex items-center gap-1.5">
<div
className="h-7 w-7 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0
bg-[rgb(var(--line))] text-[rgb(var(--fg-2))] border border-[rgb(var(--line))]"
title={session.user.name || session.user.email || "User"}
>
{initials}
</div>
<ThemeToggle />
<form action={async () => { "use server"; await signOut({ redirectTo: "/" }); }}>
<button type="submit" className="app-icon-btn" title="Sign out" aria-label="Sign out">
<LogOut size={14} strokeWidth={1.8} />
</button>
</form>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-5 py-6">{children}</main>
</div>
);
}
function AsteriskLogo() {
return (
<svg width="24" height="24" viewBox="0 0 28 28" fill="none" aria-label="Forms home">
<line x1="14" y1="3" x2="14" y2="25" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="3" y1="14" x2="25" y2="14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="5.8" y1="5.8" x2="22.2" y2="22.2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
<line x1="22.2" y1="5.8" x2="5.8" y2="22.2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { setMemberRole, removeMember } from "@/lib/actions";
import { Trash2 } from "lucide-react";
export default async function MembersPage() {
const session = await auth();
if (!session?.user) redirect("/signin");
if (session.user.role !== "admin") redirect("/app");
const members = await prisma.user.findMany({ orderBy: { createdAt: "asc" } });
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<h1 className="text-xl font-semibold">Members</h1>
<p className="text-xs text-muted">Members appear here the first time they sign in.</p>
</div>
<ul className="border border-line rounded-xl divide-y divide-line">
{members.map((m) => (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm">{m.name || "—"}</div>
<div className="text-xs text-muted">{m.email}</div>
</div>
<form action={async () => { "use server"; await setMemberRole(m.id, m.role === "admin" ? "member" : "admin"); }}>
<button className="btn btn-outline btn-sm">
{m.role === "admin" ? "Admin" : "Member"}
</button>
</form>
{m.id !== session.user.id && (
<form action={async () => { "use server"; await removeMember(m.id); }}>
<button className="btn btn-ghost btn-sm text-muted hover:text-danger" aria-label="Remove">
<Trash2 size={14}/>
</button>
</form>
)}
</li>
))}
</ul>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { createForm, createFromTemplate } from "@/lib/actions";
import { TEMPLATES } from "@/lib/templates";
import { listAllTags } from "@/lib/tags";
import { Plus, FileText, Layers } from "lucide-react";
export default async function FormsList({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
const { tag: tagFilter } = await searchParams;
const session = await auth();
const userId = session!.user.id;
const isAdmin = session!.user.role === "admin";
const baseWhere = isAdmin ? {} : {
OR: [
{ ownerId: userId },
{ viewers: { some: { userId } } },
{ published: true },
],
};
const where = tagFilter
? { AND: [baseWhere, { tags: { some: { tag: { slug: tagFilter } } } }] }
: baseWhere;
const [forms, allTags] = await Promise.all([
prisma.form.findMany({
where,
orderBy: { updatedAt: "desc" },
include: {
_count: { select: { responses: true } },
owner: { select: { name: true, email: true } },
viewers: { where: { userId }, select: { userId: true } },
tags: { include: { tag: { select: { slug: true, name: true } } } },
},
}),
listAllTags(),
]);
return (
<div className="space-y-8">
{/* Header row */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Forms</h1>
<p className="text-sm text-[rgb(var(--muted))] mt-0.5">
{forms.length === 0
? (tagFilter ? `No forms tagged “${tagFilter}` : "No forms yet")
: `${forms.length} form${forms.length === 1 ? "" : "s"}${tagFilter ? ` tagged “${tagFilter}` : ""}`}
</p>
</div>
<form action={createForm}>
<button className="btn btn-primary">
<Plus size={15} strokeWidth={2.5} />
New form
</button>
</form>
</div>
{/* Tag filter chips */}
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Link
href="/app"
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
!tagFilter
? "bg-[rgb(var(--fg))] text-[rgb(var(--bg))] border-[rgb(var(--fg))]"
: "border-[rgb(var(--line))] hover:border-[rgb(var(--fg-2))]"
}`}
>
All
</Link>
{allTags.map((t) => (
<Link
key={t.slug}
href={`/app?tag=${encodeURIComponent(t.slug)}`}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
tagFilter === t.slug
? "bg-[rgb(var(--fg))] text-[rgb(var(--bg))] border-[rgb(var(--fg))]"
: "border-[rgb(var(--line))] hover:border-[rgb(var(--fg-2))]"
}`}
>
{t.name}
<span className="ml-1 opacity-60">{t.count}</span>
</Link>
))}
</div>
)}
{/* Templates */}
<div className="card p-4">
<div className="flex items-center gap-2 mb-3">
<Layers size={14} className="text-[rgb(var(--muted))]" />
<span className="panel-label">Start from a template</span>
</div>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((t) => (
<form key={t.id} action={createFromTemplate.bind(null, t.id)}>
<button
className="btn btn-outline btn-sm"
title={t.description}
>
<span aria-hidden>{t.emoji}</span>
{t.name}
</button>
</form>
))}
</div>
</div>
{/* Forms grid */}
{forms.length === 0 ? (
<div className="card p-14 text-center">
<div className="inline-flex h-12 w-12 rounded-xl items-center justify-center mb-4
bg-[rgb(var(--line)/0.7)]">
<FileText size={22} className="text-[rgb(var(--muted))]" />
</div>
<p className="font-medium text-[rgb(var(--fg))]">
{tagFilter ? `No forms tagged “${tagFilter}` : "No forms yet"}
</p>
<p className="text-sm text-[rgb(var(--muted))] mt-1">
{tagFilter
? <>Try a different tag, or <Link href="/app" className="underline">clear the filter</Link>.</>
: "Create your first form or start from a template above."}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{forms.map((f) => {
const canEdit = isAdmin || f.ownerId === userId;
const canSeeCount = canEdit || f.viewers.length > 0;
const href = canEdit ? `/app/forms/${f.id}` : `/f/${f.slug}`;
const ownerLabel = f.owner.name || f.owner.email;
return (
<Link
key={f.id}
href={href}
className="card card-hover p-5 flex flex-col gap-4 block"
>
{/* Top row: icon + badge */}
<div className="flex items-start justify-between gap-2">
<div className="h-9 w-9 rounded-lg flex items-center justify-center shrink-0
bg-[rgb(var(--stage))]">
<FileText size={16} className="text-[rgb(var(--muted))]" />
</div>
<span className={`badge ${f.published ? "badge-green" : "badge-gray"}`}>
{f.published ? "Published" : "Draft"}
</span>
</div>
{/* Title + owner */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-[rgb(var(--fg))] truncate leading-snug">
{f.title || "Untitled form"}
</div>
<div className="text-xs text-[rgb(var(--muted))] mt-0.5 truncate">
{ownerLabel}
</div>
</div>
{/* Tags */}
{f.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{f.tags.map((ft) => (
<span
key={ft.tag.slug}
className="text-[10px] px-1.5 py-0.5 rounded-full
bg-[rgb(var(--line))] text-[rgb(var(--muted))]"
>
{ft.tag.name}
</span>
))}
</div>
)}
{/* Footer: response count */}
{canSeeCount && (
<div className="pt-3 border-t border-[rgb(var(--line-2))] text-xs text-[rgb(var(--muted))] font-medium">
{f._count.responses === 0
? "No responses yet"
: `${f._count.responses} response${f._count.responses === 1 ? "" : "s"}`}
</div>
)}
</Link>
);
})}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
// Static embed loader. Usage:
// <script src="https://YOUR_HOST/embed.js"></script>
// <button data-form="SLUG">Open form</button>
// Or programmatically: window.FormBuilder.open("SLUG").
import { NextResponse } from "next/server";
const SCRIPT = `(function(){
var BASE = document.currentScript ? new URL(document.currentScript.src).origin : location.origin;
function open(slug){
var overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);";
overlay.addEventListener("click", function(e){ if (e.target === overlay) close(); });
var box = document.createElement("div");
box.style.cssText = "position:relative;background:#fff;border-radius:14px;width:100%;max-width:640px;height:80vh;max-height:760px;overflow:hidden;box-shadow:0 30px 60px -20px rgba(0,0,0,0.4);";
var close = function(){ overlay.remove(); };
var x = document.createElement("button");
x.textContent = "\\u00D7";
x.setAttribute("aria-label","Close");
x.style.cssText = "position:absolute;top:8px;right:12px;z-index:1;background:transparent;border:0;font-size:22px;line-height:1;cursor:pointer;color:#888;";
x.addEventListener("click", close);
var iframe = document.createElement("iframe");
iframe.src = BASE + "/f/" + encodeURIComponent(slug) + "?embed=1";
iframe.style.cssText = "border:0;width:100%;height:100%;";
iframe.setAttribute("title","Form");
box.appendChild(x); box.appendChild(iframe); overlay.appendChild(box);
document.body.appendChild(overlay);
document.addEventListener("keydown", function esc(e){ if (e.key === "Escape"){ close(); document.removeEventListener("keydown", esc); } });
}
document.addEventListener("click", function(e){
var t = e.target;
while (t && t !== document) {
if (t.dataset && t.dataset.form){ e.preventDefault(); open(t.dataset.form); return; }
t = t.parentNode;
}
});
window.FormBuilder = { open: open };
})();`;
export const dynamic = "force-static";
export function GET() {
return new NextResponse(SCRIPT, {
headers: {
"content-type": "application/javascript; charset=utf-8",
"cache-control": "public, max-age=300, s-maxage=86400",
},
});
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { FormRuntime, type SubmitResult } from "@/components/runtime/FormRuntime";
import type { Field, FormSettings } from "@/lib/types";
import { pipe } from "@/lib/pipe";
export default function FormFiller({ formId, title, description, fields, settings, initialValues, hcaptchaSiteKey }: {
formId: string; title: string; description: string | null; fields: Field[]; settings: FormSettings;
initialValues?: Record<string, unknown>;
hcaptchaSiteKey?: string | null;
}) {
async function submit(values: Record<string, unknown>, meta: { hp: string; captcha?: string }): Promise<SubmitResult> {
const r = await fetch(`/api/forms/${formId}/submit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: values, _h: meta.hp, _captcha: meta.captcha }),
});
if (r.ok) {
if (settings.redirectUrl) {
window.location.replace(pipe(settings.redirectUrl, values, fields));
return { ok: true };
}
return { ok: true };
}
const j = await r.json().catch(() => ({}));
return { ok: false, error: j.error ?? "Submission failed" };
}
return <FormRuntime title={title} description={description} fields={fields} settings={settings} onSubmit={submit} initialValues={initialValues} formId={formId} hcaptchaSiteKey={hcaptchaSiteKey ?? undefined} />;
}
+66
View File
@@ -0,0 +1,66 @@
import { notFound, redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { parseFields, parseSettings } from "@/lib/forms";
import FormFiller from "./FormFiller";
function slugifyLabel(s: string) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
}
export default async function PublicForm({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const form = await prisma.form.findUnique({ where: { slug } });
if (!form) notFound();
const settings = parseSettings(form.settings);
const session = await auth();
const isOwnerOrAdmin = session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
if (!form.published && !isOwnerOrAdmin) notFound();
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
redirect(`/signin?from=/f/${slug}`);
}
const fields = parseFields(form.fields);
// URL prefill: ?label_slug=value or ?field_id=value
const initialValues: Record<string, unknown> = {};
for (const f of fields) {
const labelKey = slugifyLabel(f.label);
const raw = sp[f.id] ?? sp[labelKey];
if (raw === undefined) continue;
const v = Array.isArray(raw) ? raw[0] : raw;
if (f.type === "number") initialValues[f.id] = Number(v);
else if (f.type === "checkbox") initialValues[f.id] = v === "true" || v === "1";
else if (f.type === "multi_select") initialValues[f.id] = String(v).split(",").map((s) => s.trim()).filter(Boolean);
else initialValues[f.id] = v;
}
const embed = sp.embed === "1";
const hcaptchaSiteKey = settings.hcaptchaEnabled ? (process.env.HCAPTCHA_SITE_KEY ?? null) : null;
return (
<main className={embed ? "min-h-screen px-4 py-4" : "min-h-screen px-6 py-12"}>
<div className={embed ? "max-w-xl mx-auto" : "max-w-xl mx-auto"}>
<FormFiller
formId={form.id}
title={form.title}
description={form.description}
fields={fields}
settings={settings}
initialValues={initialValues}
hcaptchaSiteKey={hcaptchaSiteKey}
/>
</div>
</main>
);
}
+257
View File
@@ -0,0 +1,257 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Design tokens ─────────────────────────────────────────── */
:root {
--bg: 248 248 247; /* warm off-white page bg */
--surface: 255 255 255; /* elevated cards/panels */
--stage: 241 241 239; /* canvas stage, code areas */
--fg: 15 15 15;
--fg-2: 60 60 67; /* secondary text */
--muted: 107 107 116;
--line: 228 228 231;
--line-2: 241 241 243; /* very subtle dividers */
--accent: 15 15 15;
--accent-fg: 255 255 255;
--danger: 220 38 38;
--success: 22 163 74;
--warn: 161 98 7;
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.06);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 10px 24px -4px rgb(0 0 0 / 0.12), 0 4px 8px -4px rgb(0 0 0 / 0.07);
}
.dark {
--bg: 16 16 17;
--surface: 24 24 27;
--stage: 12 12 14;
--fg: 242 242 243;
--fg-2: 185 185 195;
--muted: 140 140 150;
--line: 39 39 42;
--line-2: 30 30 33;
--accent: 240 240 243;
--accent-fg: 15 15 15;
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.30);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.25);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.40), 0 2px 4px -2px rgb(0 0 0 / 0.25);
--shadow-lg: 0 10px 24px -4px rgb(0 0 0 / 0.50), 0 4px 8px -4px rgb(0 0 0 / 0.30);
}
/* ── Base ───────────────────────────────────────────────────── */
html, body {
background: rgb(var(--bg));
color: rgb(var(--fg));
}
body {
font-family: var(--font-inter, "Inter", system-ui, sans-serif);
-webkit-font-smoothing: antialiased;
font-feature-settings: "cv11", "ss01";
}
button { cursor: pointer; }
input, textarea, select { background: transparent; }
*:focus-visible {
outline: 2px solid rgb(var(--accent));
outline-offset: 2px;
border-radius: 6px;
}
/* ── Surface / card ─────────────────────────────────────────── */
.surface {
background: rgb(var(--surface));
}
.card {
background: rgb(var(--surface));
border-radius: 14px;
box-shadow: var(--shadow-sm);
border: 1px solid rgb(var(--line) / 0.6);
}
.card-hover {
transition: box-shadow 150ms ease, transform 150ms ease;
}
.card-hover:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
/* ── Badges ─────────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 99px;
font-size: 11.5px; font-weight: 500; line-height: 1.6;
white-space: nowrap;
}
.badge::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.badge-green {
background: rgb(220 252 231); color: rgb(20 120 50);
}
.badge-gray {
background: rgb(var(--line-2)); color: rgb(var(--muted));
}
.badge-orange {
background: rgb(255 237 213); color: rgb(154 52 18);
}
.dark .badge-green { background: rgb(20 83 45 / 0.4); color: rgb(74 222 128); }
.dark .badge-gray { background: rgb(var(--line)); color: rgb(var(--muted)); }
.dark .badge-orange { background: rgb(124 45 18 / 0.4); color: rgb(253 186 116); }
/* ── Field type chips (builder) ─────────────────────────────── */
.chip {
display: inline-flex; align-items: center;
padding: 2px 7px; border-radius: 6px;
font-size: 10px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
white-space: nowrap;
}
.chip-text { background: rgb(219 234 254); color: rgb(30 64 175); }
.chip-choice { background: rgb(220 252 231); color: rgb(20 83 45); }
.chip-number { background: rgb(237 233 254); color: rgb(91 33 182); }
.chip-media { background: rgb(255 237 213); color: rgb(154 52 18); }
.chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); }
.dark .chip-text { background: rgb(30 64 175 / 0.3); color: rgb(147 197 253); }
.dark .chip-choice { background: rgb(20 83 45 / 0.3); color: rgb(134 239 172); }
.dark .chip-number { background: rgb(91 33 182 / 0.3); color: rgb(196 181 253); }
.dark .chip-media { background: rgb(154 52 18 / 0.3); color: rgb(253 186 116); }
.dark .chip-special { background: rgb(var(--line)); color: rgb(var(--muted)); }
/* ── Form runtime inputs ─────────────────────────────────────── */
.field-label {
font-size: 15px; font-weight: 500; color: rgb(var(--fg)); line-height: 1.4;
}
.field-help {
font-size: 13px; color: rgb(var(--muted));
}
.field-input {
width: 100%; padding: 10px 14px;
border: 1.5px solid rgb(var(--line));
border-radius: 10px;
background: rgb(var(--surface));
color: rgb(var(--fg));
font-size: 15px;
box-shadow: var(--shadow-xs);
transition: border-color 130ms ease, box-shadow 130ms ease;
}
.field-input:hover { border-color: rgb(var(--muted) / 0.5); }
.field-input:focus {
outline: none;
border-color: rgb(var(--accent));
box-shadow: 0 0 0 3px rgb(var(--accent) / 0.10);
}
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 9px;
font-size: 13.5px; font-weight: 500; letter-spacing: -0.01em;
border: 1.5px solid transparent;
transition: all 140ms ease;
white-space: nowrap;
}
.btn-primary {
background: rgb(var(--accent));
color: rgb(var(--accent-fg));
box-shadow: var(--shadow-xs);
}
.btn-primary:hover {
opacity: 0.88;
box-shadow: var(--shadow-sm);
}
.btn-ghost {
color: rgb(var(--fg));
}
.btn-ghost:hover {
background: rgb(var(--line) / 0.7);
}
.btn-outline {
border-color: rgb(var(--line));
background: rgb(var(--surface));
color: rgb(var(--fg));
box-shadow: var(--shadow-xs);
}
.btn-outline:hover {
border-color: rgb(var(--muted) / 0.5);
box-shadow: var(--shadow-sm);
}
.btn-sm { padding: 5px 11px; font-size: 12.5px; border-radius: 8px; }
.btn-icon { padding: 6px; border-radius: 8px; }
/* ── Section label ───────────────────────────────────────────── */
.panel-label {
font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: rgb(var(--muted));
}
/* ── Pill navbar ─────────────────────────────────────────────── */
.nav-pill {
background: rgb(255 255 255 / 0.97);
border: 1px solid rgb(0 0 0 / 0.07);
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.03),
0 8px 32px -4px rgb(0 0 0 / 0.10),
inset 0 1px 0 rgb(255 255 255 / 1); /* top highlight */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.dark .nav-pill {
background: rgb(30 30 33 / 0.97);
border: 1px solid rgb(255 255 255 / 0.07);
box-shadow:
0 0 0 1px rgb(255 255 255 / 0.03),
0 8px 32px -4px rgb(0 0 0 / 0.50),
inset 0 1px 0 rgb(255 255 255 / 0.09); /* top highlight */
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.nav-pill-link {
font-size: 14px; font-weight: 500; padding: 6px 12px;
border-radius: 8px; color: rgb(var(--muted));
transition: color 140ms ease, background 140ms ease;
}
.nav-pill-link:hover {
color: rgb(var(--fg));
background: rgb(var(--line) / 0.6);
}
.nav-pill-link.active {
color: rgb(var(--fg));
}
.nav-pill-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 10px;
color: rgb(var(--fg));
background: rgb(var(--stage));
border: 1px solid rgb(var(--line));
transition: all 140ms ease;
}
.nav-pill-btn:hover {
background: rgb(var(--line));
box-shadow: var(--shadow-xs);
}
/* ── App header icon buttons ─────────────────────────────────── */
.app-icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px;
color: rgb(var(--muted));
transition: color 140ms ease, background 140ms ease;
}
.app-icon-btn:hover {
color: rgb(var(--fg));
background: rgb(var(--line-2));
}
/* ── Canvas dot-grid background ──────────────────────────────── */
.canvas-dots {
background-color: rgb(var(--stage));
background-image: radial-gradient(circle, rgb(var(--muted) / 0.18) 1px, transparent 1px);
background-size: 24px 24px;
}
+26
View File
@@ -0,0 +1,26 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeScript } from "@/components/ThemeProvider";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: "Forms",
description: "Minimalist forms for your team.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function Home() {
const session = await auth();
if (session?.user) redirect("/app");
return (
<main className="min-h-screen grid place-items-center px-6">
<div className="max-w-md w-full text-center space-y-8">
<div className="space-y-2">
<div className="inline-flex h-10 w-10 rounded-xl bg-fg text-bg items-center justify-center text-lg font-semibold mb-2">F</div>
<h1 className="text-2xl font-semibold tracking-tight">Forms</h1>
<p className="text-muted text-sm">Minimal forms for your team.</p>
</div>
<Link href="/signin" className="btn btn-primary w-full justify-center">Sign in</Link>
</div>
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { signIn, auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function SignIn({ searchParams }: { searchParams: Promise<{ from?: string }> }) {
const session = await auth();
const sp = await searchParams;
if (session?.user) redirect(sp.from || "/app");
const provider = process.env.OIDC_PROVIDER_NAME || "SSO";
return (
<main className="min-h-screen grid place-items-center px-6">
<div className="max-w-sm w-full space-y-6 text-center">
<div className="inline-flex h-10 w-10 rounded-xl bg-fg text-bg items-center justify-center text-lg font-semibold mx-auto">F</div>
<h1 className="text-xl font-semibold">Sign in</h1>
<form
action={async () => {
"use server";
await signIn("oidc", { redirectTo: sp.from || "/app" });
}}
>
<button type="submit" className="btn btn-primary w-full justify-center">
Continue with {provider}
</button>
</form>
</div>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
// Inline script injected into <head> — runs before paint to avoid flash.
export function ThemeScript() {
const code = `
(function(){
try {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch(e){}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Field, FileRef, FormSettings } from "@/lib/types";
import { visibleFields } from "@/lib/logic";
import { applyCalculations } from "@/lib/calc";
import { pipe } from "@/lib/pipe";
import { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input";
import { Star, Paperclip, X } from "lucide-react";
export type SubmitResult = { ok: true } | { ok: false; error: string };
export function FormRuntime({
title,
description,
fields,
settings,
onSubmit,
submitLabel = "Submit",
initialValues,
formId,
hcaptchaSiteKey,
}: {
title: string;
description?: string | null;
fields: Field[];
settings: FormSettings;
onSubmit: (values: Record<string, unknown>, meta: { hp: string; captcha?: string }) => Promise<SubmitResult> | SubmitResult;
submitLabel?: string;
initialValues?: Record<string, unknown>;
/** Required for file fields — passed when actually filling a form (not in preview). */
formId?: string;
/** When set, render hCaptcha and require a token to submit. */
hcaptchaSiteKey?: string;
}) {
const [values, setValues] = useState<Record<string, unknown>>(initialValues ?? {});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>("");
const [resumed, setResumed] = useState(false);
const hpRef = useRef<HTMLInputElement>(null);
const captchaRef = useRef<HTMLDivElement>(null);
const valuesRef = useRef(values);
valuesRef.current = values;
// Load partial on mount (only when we have a formId, i.e. real form filling).
useEffect(() => {
if (!formId) return;
let cancelled = false;
(async () => {
const r = await fetch(`/api/forms/${formId}/partial`).catch(() => null);
if (!r || !r.ok) return;
const j = await r.json().catch(() => null);
if (!cancelled && j && j.data && typeof j.data === "object" && Object.keys(j.data).length > 0) {
setValues((cur) => ({ ...j.data, ...cur }));
setResumed(true);
}
})();
return () => { cancelled = true; };
}, [formId]);
// Debounced save of partial on changes.
useEffect(() => {
if (!formId || done) return;
if (Object.keys(values).length === 0) return;
const t = setTimeout(() => {
fetch(`/api/forms/${formId}/partial`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: valuesRef.current }),
}).catch(() => {});
}, 1500);
return () => clearTimeout(t);
}, [values, formId, done]);
// Analytics beacons. Session-scoped key so we can correlate view→start→submit.
const sessionKey = useRef<string>("");
const startedAt = useRef<number>(0);
const startedRef = useRef(false);
const submittedRef = useRef(false);
if (!sessionKey.current) sessionKey.current = `s_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
const sendEvent = useCallbackBeacon();
useEffect(() => {
if (!formId) return;
startedAt.current = Date.now();
sendEvent(formId, { kind: "view", sessionKey: sessionKey.current });
const onPageHide = () => {
if (submittedRef.current) return;
if (!startedRef.current) return;
sendEvent(formId, { kind: "abandon", sessionKey: sessionKey.current, durationMs: Date.now() - startedAt.current });
};
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, [formId, sendEvent]);
function markStartedOnce() {
if (!formId || startedRef.current) return;
startedRef.current = true;
sendEvent(formId, { kind: "start", sessionKey: sessionKey.current });
}
// Auto-compute calculated fields into the values map (read-only to the user).
const computed = useMemo(() => applyCalculations(fields, values), [fields, values]);
const shown = useMemo(() => visibleFields(fields, computed), [fields, computed]);
// Split visible fields into pages based on layout setting + page_break markers.
// Drops page_break fields from the rendered output — they're structural only.
const pages = useMemo<Field[][]>(() => {
const layout = settings.layout ?? "one_page";
const renderable = shown.filter((f) => f.type !== "page_break");
if (layout === "one_page") return [renderable];
if (layout === "one_at_a_time") return renderable.map((f) => [f]);
// "paged" — split at page_break markers using their position in `shown`.
const out: Field[][] = [];
let current: Field[] = [];
for (const f of shown) {
if (f.type === "page_break") { if (current.length) out.push(current); current = []; }
else current.push(f);
}
if (current.length) out.push(current);
return out.length ? out : [[]];
}, [shown, settings.layout]);
const [pageIdx, setPageIdx] = useState(0);
useEffect(() => { if (pageIdx >= pages.length) setPageIdx(Math.max(0, pages.length - 1)); }, [pageIdx, pages.length]);
const isLast = pageIdx >= pages.length - 1;
const currentPage = pages[pageIdx] ?? [];
// hCaptcha: load script + render widget when a site key is provided.
useEffect(() => {
if (!hcaptchaSiteKey || !captchaRef.current) return;
let mounted = true;
let widgetId: number | undefined;
const SRC = "https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off";
function render() {
type HC = { render: (el: HTMLElement, opts: Record<string, unknown>) => number; reset: (id?: number) => void };
const hc = (window as unknown as { hcaptcha?: HC }).hcaptcha;
if (!hc || !mounted || !captchaRef.current) return;
widgetId = hc.render(captchaRef.current, {
sitekey: hcaptchaSiteKey,
callback: (t: string) => setCaptchaToken(t),
"expired-callback": () => setCaptchaToken(""),
"error-callback": () => setCaptchaToken(""),
});
}
const existing = document.querySelector(`script[src^="https://js.hcaptcha.com"]`) as HTMLScriptElement | null;
if (existing) {
const w = window as unknown as { hcaptcha?: unknown };
if (w.hcaptcha) render();
else existing.addEventListener("load", render, { once: true });
} else {
const s = document.createElement("script");
s.src = SRC; s.async = true; s.defer = true;
s.onload = render;
document.head.appendChild(s);
}
return () => {
mounted = false;
const w = window as unknown as { hcaptcha?: { reset: (id?: number) => void } };
if (w.hcaptcha && widgetId !== undefined) try { w.hcaptcha.reset(widgetId); } catch { /* widget already gone */ }
};
}, [hcaptchaSiteKey]);
function set(id: string, v: unknown) {
markStartedOnce();
setValues((s) => ({ ...s, [id]: v }));
setErrors((e) => ({ ...e, [id]: "" }));
}
function validate(scope: Field[] = shown): boolean {
const next: Record<string, string> = {};
for (const f of scope) {
if (f.type === "page_break") continue;
const v = values[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) { next[f.id] = "Required"; continue; }
if (!empty && f.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))) {
next[f.id] = "Invalid email";
}
if (!empty && f.type === "number") {
const n = Number(v);
if (Number.isNaN(n)) next[f.id] = "Must be a number";
else if (f.min !== undefined && n < f.min) next[f.id] = `Min ${f.min}`;
else if (f.max !== undefined && n > f.max) next[f.id] = `Max ${f.max}`;
}
}
setErrors(next);
return Object.keys(next).length === 0;
}
function goNext() {
if (!validate(currentPage)) return;
setPageIdx((p) => Math.min(pages.length - 1, p + 1));
}
function goBack() { setPageIdx((p) => Math.max(0, p - 1)); }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
if (hcaptchaSiteKey && !captchaToken) {
setErrors({ _form: "Please complete the captcha." });
return;
}
setSubmitting(true);
try {
// Drop values for hidden fields.
const filtered: Record<string, unknown> = {};
for (const f of shown) if (values[f.id] !== undefined) filtered[f.id] = values[f.id];
const r = await onSubmit(filtered, { hp: hpRef.current?.value ?? "", captcha: captchaToken || undefined });
if (r.ok) {
submittedRef.current = true;
if (formId) sendEvent(formId, { kind: "submit", sessionKey: sessionKey.current, durationMs: Date.now() - (startedAt.current || Date.now()) });
setDone(true);
} else {
setErrors({ _form: r.error });
}
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<div className="text-center py-16 space-y-3">
<div className="text-3xl"></div>
<p className="text-muted">{pipe(settings.thanksMessage || "Thanks.", computed, fields)}</p>
</div>
);
}
const fontStack = settings.font ? `"${settings.font}", ui-sans-serif, system-ui, sans-serif` : undefined;
return (
<form
onSubmit={handleSubmit}
className="space-y-6"
style={fontStack ? { fontFamily: fontStack } : undefined}
>
{/* Honeypot — bots fill it; humans never see it. */}
<div aria-hidden="true" style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }}>
<label>Leave this empty
<input ref={hpRef} type="text" name="_h" tabIndex={-1} autoComplete="off" defaultValue="" />
</label>
</div>
{settings.coverImage && pageIdx === 0 && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/files/${settings.coverImage}`} alt="" className="w-full h-40 object-cover rounded-xl" />
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
{settings.logo && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/files/${settings.logo}`} alt="" className="h-8 w-auto" />
)}
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
</div>
{description && pageIdx === 0 && <p className="text-muted whitespace-pre-line text-sm">{description}</p>}
{resumed && (
<div className="flex items-center gap-2 text-xs text-muted">
<span>Continuing where you left off.</span>
<button type="button" onClick={() => { setValues({}); setResumed(false); }} className="underline hover:text-fg">Start over</button>
</div>
)}
</div>
{pages.length > 1 && (
<div className="space-y-1">
<div className="h-1 bg-line/60 rounded-full overflow-hidden">
<div
className="h-full bg-fg transition-[width]"
style={{ width: `${((pageIdx + 1) / pages.length) * 100}%` }}
/>
</div>
<div className="text-[11px] text-muted text-right">{pageIdx + 1} / {pages.length}</div>
</div>
)}
<div className="space-y-5">
{currentPage.map((f) => {
const piped: Field = { ...f, label: pipe(f.label, computed, fields), help: f.help ? pipe(f.help, computed, fields) : f.help };
return <FieldInput key={f.id} field={piped} value={computed[f.id]} error={errors[f.id]} onChange={(v) => set(f.id, v)} accent={settings.accent} formId={formId} />;
})}
</div>
{isLast && hcaptchaSiteKey && <div ref={captchaRef} className="my-2" />}
{errors._form && <p className="text-sm text-danger">{errors._form}</p>}
<div className="flex items-center gap-2">
{pages.length > 1 && pageIdx > 0 && (
<Button type="button" variant="outline" onClick={goBack}>Back</Button>
)}
{!isLast ? (
<Button type="button" variant="primary" onClick={goNext}>Next</Button>
) : (
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? "…" : submitLabel}
</Button>
)}
</div>
</form>
);
}
function FieldInput({ field, value, error, onChange, accent, formId }: {
field: Field; value: unknown; error?: string; onChange: (v: unknown) => void; accent?: string; formId?: string;
}) {
const id = `f-${field.id}`;
return (
<div className="space-y-1.5">
<label htmlFor={id} className="field-label inline-flex items-center gap-1">
{field.label || <span className="text-muted italic">Untitled</span>}
{field.required && <span className="text-danger">*</span>}
</label>
{field.help && <p className="field-help">{field.help}</p>}
{renderControl(field, value, onChange, id, accent, formId)}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
function renderControl(f: Field, value: unknown, onChange: (v: unknown) => void, id: string, accent?: string, formId?: string) {
switch (f.type) {
case "short_text":
case "email":
return <Input id={id} type={f.type === "email" ? "email" : "text"} value={String(value ?? "")}
placeholder={f.placeholder} onChange={(e) => onChange(e.target.value)} />;
case "long_text":
return <Textarea id={id} value={String(value ?? "")} placeholder={f.placeholder} onChange={(e) => onChange(e.target.value)} />;
case "number":
return <Input id={id} type="number" value={value === undefined ? "" : String(value)}
min={f.min} max={f.max} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} />;
case "date":
return <Input id={id} type="date" value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />;
case "checkbox":
return (
<label className="inline-flex items-center gap-2 text-sm">
<input id={id} type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
<span>{f.placeholder || "Yes"}</span>
</label>
);
case "select":
return (
<div className="flex flex-wrap gap-2">
{(f.options ?? []).map((o) => (
<button key={o.value} type="button"
onClick={() => onChange(o.value)}
className={`btn btn-sm ${value === o.value ? "btn-primary" : "btn-outline"}`}
style={value === o.value && accent ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
))}
</div>
);
case "multi_select": {
const arr = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-wrap gap-2">
{(f.options ?? []).map((o) => {
const on = arr.includes(o.value);
return (
<button key={o.value} type="button"
onClick={() => onChange(on ? arr.filter((v) => v !== o.value) : [...arr, o.value])}
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
style={on && accent ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
);
})}
</div>
);
}
case "rating": {
const max = f.max ?? 5;
const cur = Number(value || 0);
return (
<div className="flex gap-1">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => onChange(n)} className="btn-icon btn-ghost"
aria-label={`${n} of ${max}`}>
<Star size={20} fill={n <= cur ? "currentColor" : "none"} />
</button>
))}
</div>
);
}
case "file":
return <FileField id={id} field={f} value={value as FileRef | undefined} onChange={onChange} formId={formId} />;
case "signature":
return <SignatureField id={id} value={value as FileRef | undefined} onChange={onChange} formId={formId} />;
case "page_break":
return null;
case "calculated":
return (
<div className="px-3 py-2 border border-line rounded-lg text-sm text-muted bg-line/20">
{value === null || value === undefined || value === "" ? <span className="italic"></span> : String(value)}
</div>
);
}
}
function FileField({ id, field, value, onChange, formId }: {
id: string; field: Field; value: FileRef | undefined; onChange: (v: unknown) => void; formId?: string;
}) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!formId) { setError("Preview only — not uploadable."); return; }
const maxMB = field.maxSizeMB ?? 25;
if (file.size > maxMB * 1024 * 1024) { setError(`Max ${maxMB} MB`); return; }
setError(null);
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("formId", formId);
const r = await fetch("/api/files/upload", { method: "POST", body: fd });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
setError(j.error || "Upload failed");
return;
}
const ref = (await r.json()) as FileRef;
onChange(ref);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="space-y-1.5">
{value ? (
<div className="flex items-center gap-2 px-3 py-2 border border-line rounded-lg text-sm">
<Paperclip size={14} className="text-muted shrink-0" />
<span className="truncate flex-1">{value.name}</span>
<span className="text-xs text-muted shrink-0">{formatBytes(value.size)}</span>
<button type="button" onClick={() => onChange(undefined)} className="text-muted hover:text-danger" aria-label="Remove">
<X size={14} />
</button>
</div>
) : (
<label htmlFor={id} className="btn btn-outline btn-sm cursor-pointer inline-flex">
<Paperclip size={14}/>
{uploading ? "Uploading…" : (field.placeholder || "Choose file")}
<input ref={inputRef} id={id} type="file" accept={field.accept} onChange={onPick} className="sr-only" disabled={uploading} />
</label>
)}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
function useCallbackBeacon() {
return useCallback((formId: string, payload: Record<string, unknown>) => {
try {
const url = `/api/forms/${formId}/event`;
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
} else {
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});
}
} catch { /* don't surface analytics failures */ }
}, []);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function SignatureField({ id, value, onChange, formId }: {
id: string; value: FileRef | undefined; onChange: (v: unknown) => void; formId?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const dirty = useRef(false);
const [uploading, setUploading] = useState(false);
// Init canvas with white background and resolution.
useEffect(() => {
const c = canvasRef.current;
if (!c) return;
const ratio = window.devicePixelRatio || 1;
c.width = 600 * ratio; c.height = 200 * ratio;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.scale(ratio, ratio);
ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.strokeStyle = "#111";
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, c.width, c.height);
}, []);
function pos(e: React.PointerEvent<HTMLCanvasElement>) {
const c = canvasRef.current!;
const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left), y: (e.clientY - r.top) };
}
function down(e: React.PointerEvent<HTMLCanvasElement>) {
drawing.current = true; dirty.current = true;
const ctx = canvasRef.current?.getContext("2d"); if (!ctx) return;
const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y);
(e.target as Element).setPointerCapture(e.pointerId);
}
function move(e: React.PointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return;
const ctx = canvasRef.current?.getContext("2d"); if (!ctx) return;
const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke();
}
async function up() {
drawing.current = false;
if (!dirty.current || !canvasRef.current || !formId) return;
setUploading(true);
try {
const blob: Blob = await new Promise((res) => canvasRef.current!.toBlob((b) => res(b!), "image/png"));
const fd = new FormData();
fd.append("file", new File([blob], `signature-${Date.now()}.png`, { type: "image/png" }));
fd.append("formId", formId);
const r = await fetch("/api/files/upload", { method: "POST", body: fd });
if (r.ok) onChange(await r.json());
} finally { setUploading(false); }
}
function clear() {
const c = canvasRef.current; if (!c) return;
const ctx = c.getContext("2d"); if (!ctx) return;
ctx.save(); ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, c.width, c.height); ctx.restore();
dirty.current = false;
onChange(undefined);
}
return (
<div className="space-y-1.5">
<canvas
ref={canvasRef} id={id}
style={{ width: 600, height: 200, maxWidth: "100%" }}
className="border border-line rounded-lg bg-white touch-none cursor-crosshair"
onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerLeave={up}
/>
<div className="flex items-center gap-2 text-xs">
<button type="button" onClick={clear} className="text-muted hover:text-fg">Clear</button>
{uploading && <span className="text-muted">Saving</span>}
{value && !uploading && <span className="text-muted">Signed </span>}
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { cn } from "@/lib/cn";
import { forwardRef, ButtonHTMLAttributes } from "react";
type Variant = "primary" | "ghost" | "outline" | "danger";
type Size = "md" | "sm" | "icon";
export const Button = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant; size?: Size;
}>(({ variant = "outline", size = "md", className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"btn",
variant === "primary" && "btn-primary",
variant === "ghost" && "btn-ghost",
variant === "outline" && "btn-outline",
variant === "danger" && "bg-danger text-white",
size === "sm" && "btn-sm",
size === "icon" && "btn-icon",
className,
)}
{...props}
/>
);
});
Button.displayName = "Button";
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { cn } from "@/lib/cn";
import { forwardRef, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cn("field-input", className)} {...props} />
),
);
Input.displayName = "Input";
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea ref={ref} className={cn("field-input min-h-[88px] resize-y", className)} {...props} />
),
);
Textarea.displayName = "Textarea";
+34
View File
@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const path = usePathname();
return (
<nav className="flex items-center gap-0.5">
<NavItem href="/app" active={path === "/app"}>Forms</NavItem>
{isAdmin && <NavItem href="/app/members" active={path.startsWith("/app/members")}>Members</NavItem>}
{isAdmin && <NavItem href="/app/audit" active={path.startsWith("/app/audit")}>Audit</NavItem>}
<NavItem href="/app/account" active={path.startsWith("/app/account")}>Account</NavItem>
</nav>
);
}
function NavItem({ href, active, children }: {
href: string; active: boolean; children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
${active
? "text-[rgb(var(--fg))] bg-[rgb(var(--stage))]"
: "text-[rgb(var(--muted))] hover:text-[rgb(var(--fg))] hover:bg-[rgb(var(--line-2))]"
}`}
>
{children}
</Link>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, X, Tag as TagIcon } from "lucide-react";
import { setTagsForForm } from "@/lib/actions";
type Tag = { slug: string; name: string };
export default function TagBar({
formId,
initialTags,
allTags,
}: {
formId: string;
initialTags: Tag[];
allTags: Array<{ slug: string; name: string; count: number }>;
}) {
const [tags, setTags] = useState<Tag[]>(initialTags);
const [adding, setAdding] = useState(false);
const [input, setInput] = useState("");
const [pending, startTransition] = useTransition();
function commit(next: Tag[]) {
setTags(next);
startTransition(async () => {
await setTagsForForm(formId, next.map((t) => t.name));
});
}
function addTag(name: string) {
const trimmed = name.trim();
if (!trimmed) return;
if (tags.some((t) => t.name.toLowerCase() === trimmed.toLowerCase())) return;
commit([...tags, { name: trimmed, slug: trimmed.toLowerCase().replace(/\s+/g, "-") }]);
setInput("");
setAdding(false);
}
function removeTag(slug: string) {
commit(tags.filter((t) => t.slug !== slug));
}
const suggestions = input.length > 0
? allTags.filter((t) =>
t.name.toLowerCase().includes(input.toLowerCase()) &&
!tags.some((existing) => existing.slug === t.slug)
).slice(0, 6)
: allTags.filter((t) => !tags.some((existing) => existing.slug === t.slug)).slice(0, 6);
return (
<div className="flex items-center gap-2 flex-wrap text-sm">
<TagIcon size={14} className="text-[rgb(var(--muted))]" />
{tags.map((t) => (
<span
key={t.slug}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs
bg-[rgb(var(--line))] text-[rgb(var(--fg))]"
>
{t.name}
<button
type="button"
onClick={() => removeTag(t.slug)}
className="opacity-60 hover:opacity-100"
aria-label={`Remove ${t.name}`}
>
<X size={10} strokeWidth={2.5} />
</button>
</span>
))}
{adding ? (
<div className="relative">
<input
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addTag(input); }
else if (e.key === "Escape") { setAdding(false); setInput(""); }
}}
onBlur={() => setTimeout(() => setAdding(false), 100)}
placeholder="tag name…"
className="text-xs px-2 py-0.5 rounded-full border border-[rgb(var(--line))]
bg-[rgb(var(--bg))] outline-none focus:border-[rgb(var(--fg-2))] w-32"
/>
{suggestions.length > 0 && (
<div className="absolute left-0 top-full mt-1 min-w-[140px] z-50
bg-[rgb(var(--bg))] border border-[rgb(var(--line))] rounded-lg
shadow-md py-1">
{suggestions.map((s) => (
<button
key={s.slug}
type="button"
onMouseDown={(e) => { e.preventDefault(); addTag(s.name); }}
className="block w-full text-left px-3 py-1 text-xs hover:bg-[rgb(var(--line))]"
>
{s.name} <span className="text-[rgb(var(--muted))]">({s.count})</span>
</button>
))}
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
disabled={pending}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs
border border-dashed border-[rgb(var(--line))] text-[rgb(var(--muted))]
hover:text-[rgb(var(--fg))] hover:border-[rgb(var(--fg-2))]"
>
<Plus size={11} strokeWidth={2.5} />
add tag
</button>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);
const toggle = () => {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try { localStorage.setItem("theme", next ? "dark" : "light"); } catch {}
};
return (
<button
onClick={toggle}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
className="app-icon-btn"
title={dark ? "Light mode" : "Dark mode"}
>
{dark ? (
// Sun icon
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="3"/>
<line x1="8" y1="1" x2="8" y2="2.5"/>
<line x1="8" y1="13.5" x2="8" y2="15"/>
<line x1="1" y1="8" x2="2.5" y2="8"/>
<line x1="13.5" y1="8" x2="15" y2="8"/>
<line x1="3.05" y1="3.05" x2="4.1" y2="4.1"/>
<line x1="11.9" y1="11.9" x2="12.95" y2="12.95"/>
<line x1="12.95" y1="3.05" x2="11.9" y2="4.1"/>
<line x1="4.1" y1="11.9" x2="3.05" y2="12.95"/>
</svg>
) : (
// Moon icon
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 10A6 6 0 0 1 6 2.5a6 6 0 1 0 7.5 7.5z"/>
</svg>
)}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { cn } from "@/lib/cn";
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<label className="inline-flex items-center gap-2 text-sm cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative w-9 h-5 rounded-full transition-colors",
checked ? "bg-fg" : "bg-line",
)}
>
<span className={cn(
"absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-bg shadow-sm transition-transform",
checked && "translate-x-4",
)}/>
</button>
{label && <span>{label}</span>}
</label>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { nanoid } from "nanoid";
import { auth } from "./auth";
import { prisma } from "./db";
import type { Field, FormSettings } from "./types";
import { canEditForm, parseFields, parseSettings } from "./forms";
import { recordAudit } from "./audit";
import { snapshotForm } from "./versions";
import { setFormTags } from "./tags";
async function requireUser() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return session.user;
}
export async function createForm() {
const user = await requireUser();
const form = await prisma.form.create({
data: {
slug: nanoid(10),
title: "Untitled form",
ownerId: user.id,
fields: "[]",
settings: JSON.stringify({ visibility: "workspace" } satisfies FormSettings),
},
});
// Seed version 1.
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title: form.title },
});
redirect(`/app/forms/${form.id}`);
}
export async function createFromTemplate(templateId: string) {
const user = await requireUser();
const { getTemplate } = await import("./templates");
const tpl = getTemplate(templateId);
if (!tpl) throw new Error("Template not found");
// Rewrite field ids so multiple template clones don't collide, and rewrite
// any showIf rules to point at the new ids.
const idMap = new Map<string, string>();
const newFields = tpl.form.fields.map((f) => {
const newId = nanoid(8);
idMap.set(f.id, newId);
return { ...f, id: newId };
});
for (const f of newFields) {
if (!f.showIf) continue;
const groups = "fieldId" in (f.showIf[0] as object)
? [{ rules: f.showIf as { fieldId: string; op: string; value?: unknown }[] }]
: (f.showIf as { rules: { fieldId: string; op: string; value?: unknown }[] }[]);
f.showIf = groups.map((g) => ({
rules: g.rules.map((r) => ({ ...r, fieldId: idMap.get(r.fieldId) ?? r.fieldId })),
})) as typeof f.showIf;
}
const form = await prisma.form.create({
data: {
slug: nanoid(10),
title: tpl.form.title,
description: tpl.form.description ?? null,
ownerId: user.id,
fields: JSON.stringify(newFields),
settings: JSON.stringify(tpl.form.settings ?? { visibility: "workspace" }),
},
});
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title: form.title, template: templateId },
});
redirect(`/app/forms/${form.id}`);
}
export async function deleteForm(id: string) {
const user = await requireUser();
if (!(await canEditForm(id, user.id, user.role))) throw new Error("Forbidden");
const f = await prisma.form.findUnique({ where: { id }, select: { title: true } });
await prisma.form.delete({ where: { id } });
await recordAudit({
actorId: user.id, action: "form.deleted", entityType: "form", entityId: id,
metadata: { title: f?.title ?? null },
});
revalidatePath("/app");
}
export async function saveForm(id: string, patch: {
title?: string;
description?: string | null;
fields?: Field[];
settings?: FormSettings;
published?: boolean;
}) {
const user = await requireUser();
if (!(await canEditForm(id, user.id, user.role))) throw new Error("Forbidden");
const before = await prisma.form.findUnique({ where: { id } });
if (!before) throw new Error("Form not found");
const data: Record<string, unknown> = {};
if (patch.title !== undefined) data.title = patch.title;
if (patch.description !== undefined) data.description = patch.description;
if (patch.fields !== undefined) data.fields = JSON.stringify(patch.fields);
if (patch.settings !== undefined) data.settings = JSON.stringify(patch.settings);
if (patch.published !== undefined) data.published = patch.published;
const after = await prisma.form.update({ where: { id }, data });
// Snapshot only if the schema or meta actually changed.
await snapshotForm({
formId: id,
current: { title: after.title, description: after.description, fields: after.fields, settings: after.settings },
authorId: user.id,
});
// Specific publish/unpublish audit events for nicer feed entries.
if (patch.published !== undefined && patch.published !== before.published) {
await recordAudit({
actorId: user.id,
action: patch.published ? "form.published" : "form.unpublished",
entityType: "form", entityId: id,
metadata: { title: after.title },
});
} else {
await recordAudit({
actorId: user.id, action: "form.updated", entityType: "form", entityId: id,
metadata: { title: after.title, fields: patch.fields !== undefined, settings: patch.settings !== undefined },
});
}
revalidatePath(`/app/forms/${id}`);
revalidatePath("/app");
}
export async function setViewers(formId: string, userIds: string[]) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
await prisma.$transaction([
prisma.formViewer.deleteMany({ where: { formId } }),
prisma.formViewer.createMany({ data: userIds.map((userId) => ({ formId, userId })) }),
]);
await recordAudit({
actorId: user.id, action: "form.viewers_set", entityType: "form", entityId: formId,
metadata: { count: userIds.length },
});
revalidatePath(`/app/forms/${formId}`);
}
export async function setMemberRole(userId: string, role: "admin" | "member") {
const user = await requireUser();
if (user.role !== "admin") throw new Error("Forbidden");
const before = await prisma.user.findUnique({ where: { id: userId }, select: { role: true, email: true } });
await prisma.user.update({ where: { id: userId }, data: { role } });
await recordAudit({
actorId: user.id, action: "member.role_changed", entityType: "user", entityId: userId,
metadata: { from: before?.role ?? null, to: role, email: before?.email ?? null },
});
revalidatePath("/app/members");
}
export async function removeMember(userId: string) {
const user = await requireUser();
if (user.role !== "admin") throw new Error("Forbidden");
if (userId === user.id) throw new Error("Cannot remove yourself");
const before = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
await prisma.user.delete({ where: { id: userId } });
await recordAudit({
actorId: user.id, action: "member.removed", entityType: "user", entityId: userId,
metadata: { email: before?.email ?? null },
});
revalidatePath("/app/members");
}
// Replace this form's tags. Empty array clears all tags.
export async function setTagsForForm(formId: string, tagNames: string[]) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
await setFormTags(formId, tagNames);
await recordAudit({
actorId: user.id, action: "form.tagged", entityType: "form", entityId: formId,
metadata: { tags: tagNames },
});
revalidatePath(`/app/forms/${formId}`);
revalidatePath("/app");
}
// Revert a form to a prior version. Creates a new version atop rather than
// destroying the forward history.
export async function revertFormToVersion(formId: string, version: number) {
const user = await requireUser();
if (!(await canEditForm(formId, user.id, user.role))) throw new Error("Forbidden");
const snap = await prisma.formVersion.findUnique({ where: { formId_version: { formId, version } } });
if (!snap) throw new Error("Version not found");
await prisma.form.update({
where: { id: formId },
data: {
title: snap.title,
description: snap.description,
fields: snap.fields,
settings: snap.settings,
},
});
await snapshotForm({
formId,
current: { title: snap.title, description: snap.description, fields: snap.fields, settings: snap.settings },
authorId: user.id,
force: true,
});
await recordAudit({
actorId: user.id, action: "form.reverted", entityType: "form", entityId: formId,
metadata: { revertedTo: version },
});
revalidatePath(`/app/forms/${formId}`);
}
// Wipe a single response. Owner/admin/viewer-with-permission only.
export async function deleteResponse(responseId: string) {
const user = await requireUser();
const r = await prisma.response.findUnique({ where: { id: responseId }, select: { formId: true } });
if (!r) throw new Error("Not found");
if (!(await canEditForm(r.formId, user.id, user.role))) throw new Error("Forbidden");
await prisma.response.delete({ where: { id: responseId } });
await recordAudit({
actorId: user.id, action: "response.deleted", entityType: "response", entityId: responseId,
metadata: { formId: r.formId },
});
revalidatePath(`/app/forms/${r.formId}/responses`);
}
// Re-export for convenience so callers can stay in one import.
export { parseFields, parseSettings };
+66
View File
@@ -0,0 +1,66 @@
// Append-only audit log. Failures are swallowed so a logging hiccup never
// breaks a real mutation — audit is observability, not a transactional guarantee.
import { prisma } from "./db";
export type AuditAction =
// Forms
| "form.created"
| "form.updated"
| "form.deleted"
| "form.published"
| "form.unpublished"
| "form.reverted"
| "form.viewers_set"
| "form.tagged"
| "form.untagged"
// Responses
| "response.deleted"
// Members
| "member.role_changed"
| "member.removed"
// Tokens
| "token.created"
| "token.revoked"
// Webhooks
| "webhook.retried";
export type AuditEntity =
| "form"
| "response"
| "user"
| "token"
| "webhook"
| "tag";
export async function recordAudit(opts: {
actorId?: string | null;
action: AuditAction;
entityType: AuditEntity;
entityId?: string | null;
metadata?: Record<string, unknown>;
}): Promise<void> {
try {
await prisma.auditLog.create({
data: {
actorId: opts.actorId ?? null,
action: opts.action,
entityType: opts.entityType,
entityId: opts.entityId ?? null,
metadata: JSON.stringify(opts.metadata ?? {}),
},
});
} catch {
// Intentional swallow — see header.
}
}
export function parseAuditMeta(raw: string | null | undefined): Record<string, unknown> {
if (!raw) return {};
try {
const v = JSON.parse(raw);
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { getOidcConfig } from "./auth-config";
describe("getOidcConfig", () => {
test("requires the Authentik OIDC environment variables", () => {
expect(() =>
getOidcConfig({
OIDC_ISSUER: "",
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
}),
).toThrow("OIDC_ISSUER");
});
test("normalizes Authentik issuer and provider name", () => {
const config = getOidcConfig({
OIDC_ISSUER: "https://auth.example.com/application/o/forms",
OIDC_CLIENT_ID: "client",
OIDC_CLIENT_SECRET: "secret",
});
expect(config).toEqual({
issuer: "https://auth.example.com/application/o/forms/",
clientId: "client",
clientSecret: "secret",
providerName: "Authentik",
});
});
});
+28
View File
@@ -0,0 +1,28 @@
type OidcEnv = {
[key: string]: string | undefined;
OIDC_ISSUER?: string;
OIDC_CLIENT_ID?: string;
OIDC_CLIENT_SECRET?: string;
OIDC_PROVIDER_NAME?: string;
};
export function getOidcConfig(env: OidcEnv = process.env) {
const issuer = requiredEnv(env.OIDC_ISSUER, "OIDC_ISSUER");
const clientId = requiredEnv(env.OIDC_CLIENT_ID, "OIDC_CLIENT_ID");
const clientSecret = requiredEnv(env.OIDC_CLIENT_SECRET, "OIDC_CLIENT_SECRET");
return {
issuer: issuer.endsWith("/") ? issuer : `${issuer}/`,
clientId,
clientSecret,
providerName: env.OIDC_PROVIDER_NAME?.trim() || "Authentik",
};
}
function requiredEnv(value: string | undefined, name: string) {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error(`${name} is required for Authentik OIDC sign-in.`);
}
return trimmed;
}
+2
View File
@@ -0,0 +1,2 @@
import { handlers } from "./auth";
export const { GET, POST } = handlers;
+62
View File
@@ -0,0 +1,62 @@
import NextAuth, { type DefaultSession } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./db";
import { getOidcConfig } from "./auth-config";
declare module "next-auth" {
interface Session {
user: { id: string; role: string } & DefaultSession["user"];
}
interface User { role?: string }
}
const bootstrapAdmins = (process.env.AUTH_BOOTSTRAP_ADMINS ?? "")
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
const oidc = getOidcConfig();
const next = NextAuth({
adapter: PrismaAdapter(prisma),
trustHost: true,
providers: [
{
id: "oidc",
name: oidc.providerName,
type: "oidc",
issuer: oidc.issuer,
clientId: oidc.clientId,
clientSecret: oidc.clientSecret,
authorization: { params: { scope: "openid profile email" } },
checks: ["pkce", "state"],
profile(p: Record<string, unknown>) {
return {
id: String(p.sub),
name: (p.name as string) || (p.preferred_username as string) || null,
email: (p.email as string) || null,
image: (p.picture as string) || null,
};
},
},
],
session: { strategy: "database" },
pages: { signIn: "/signin" },
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
session.user.role = (user as { role?: string }).role ?? "member";
return session;
},
},
events: {
async createUser({ user }) {
if (!user.id || !user.email) return;
const count = await prisma.user.count();
const isBootstrap = bootstrapAdmins.includes(user.email.toLowerCase());
if (count === 1 || isBootstrap) {
await prisma.user.update({ where: { id: user.id }, data: { role: "admin" } });
}
},
},
});
export const { handlers, signIn, signOut } = next;
export const { auth } = next;
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { evalExpr, applyCalculations } from "./calc";
import type { Field } from "./types";
describe("evalExpr — arithmetic", () => {
it("evaluates simple arithmetic", () => {
expect(evalExpr("1 + 2", {})).toBe(3);
expect(evalExpr("10 - 4", {})).toBe(6);
expect(evalExpr("3 * 4", {})).toBe(12);
expect(evalExpr("12 / 4", {})).toBe(3);
expect(evalExpr("10 % 3", {})).toBe(1);
});
it("honors operator precedence", () => {
expect(evalExpr("2 + 3 * 4", {})).toBe(14);
expect(evalExpr("(2 + 3) * 4", {})).toBe(20);
expect(evalExpr("10 - 2 - 3", {})).toBe(5); // left-associative
});
it("handles unary minus", () => {
expect(evalExpr("-5 + 10", {})).toBe(5);
expect(evalExpr("-(2 + 3)", {})).toBe(-5);
});
it("returns 0 on divide by zero (graceful, not NaN)", () => {
expect(evalExpr("5 / 0", {})).toBe(0);
});
it("concatenates strings with +", () => {
expect(evalExpr("'hello ' + 'world'", {})).toBe("hello world");
expect(evalExpr("'count: ' + 5", {})).toBe("count: 5");
});
});
describe("evalExpr — variables", () => {
it("looks up bare identifiers from the values map", () => {
expect(evalExpr("a + b", { a: 3, b: 4 })).toBe(7);
});
it("returns null for missing variables", () => {
expect(evalExpr("nonexistent", {})).toBeNull();
});
it("recognizes true/false/null constants", () => {
expect(evalExpr("true", {})).toBe(true);
expect(evalExpr("false", {})).toBe(false);
expect(evalExpr("null", {})).toBeNull();
});
});
describe("evalExpr — comparisons & logic", () => {
it("compares numbers", () => {
expect(evalExpr("5 > 3", {})).toBe(true);
expect(evalExpr("3 >= 3", {})).toBe(true);
expect(evalExpr("2 < 1", {})).toBe(false);
expect(evalExpr("4 == 4", {})).toBe(true);
expect(evalExpr("4 != 5", {})).toBe(true);
});
it("short-circuits && and ||", () => {
expect(evalExpr("true && false", {})).toBe(false);
expect(evalExpr("true || false", {})).toBe(true);
expect(evalExpr("1 && 2", {})).toBe(true); // truthy && truthy → true
});
it("handles ! unary", () => {
expect(evalExpr("!true", {})).toBe(false);
expect(evalExpr("!false", {})).toBe(true);
expect(evalExpr("!0", {})).toBe(true);
});
});
describe("evalExpr — functions", () => {
it("if(condition, then, else)", () => {
expect(evalExpr("if(true, 1, 2)", {})).toBe(1);
expect(evalExpr("if(false, 1, 2)", {})).toBe(2);
expect(evalExpr("if(0, 'a', 'b')", {})).toBe("b");
});
it("sum, avg, min, max flatten and tolerate non-numbers", () => {
expect(evalExpr("sum(1, 2, 3)", {})).toBe(6);
expect(evalExpr("avg(2, 4, 6)", {})).toBe(4);
expect(evalExpr("min(5, 2, 8)", {})).toBe(2);
expect(evalExpr("max(5, 2, 8)", {})).toBe(8);
});
it("avg on empty input returns 0, not NaN", () => {
expect(evalExpr("avg()", {})).toBe(0);
});
it("count skips empty values", () => {
expect(evalExpr("count(1, '', 2, null, 3)", {})).toBe(3);
});
it("len on strings and arrays", () => {
expect(evalExpr("len('hello')", {})).toBe(5);
expect(evalExpr("len(items)", { items: [1, 2, 3] })).toBe(3);
});
it("num coerces to a number", () => {
expect(evalExpr("num('42')", {})).toBe(42);
expect(evalExpr("num(null)", {})).toBe(0);
});
});
describe("evalExpr — safety", () => {
it("returns null on syntax errors (doesn't throw)", () => {
expect(evalExpr("1 +", {})).toBeNull();
expect(evalExpr("(((", {})).toBeNull();
expect(evalExpr("@@@", {})).toBeNull();
});
it("rejects unknown functions safely", () => {
expect(evalExpr("nope(1, 2)", {})).toBeNull();
});
it("does not let identifiers be eval'd as JS (no prototype, no global)", () => {
expect(evalExpr("constructor", {})).toBeNull();
expect(evalExpr("__proto__", {})).toBeNull();
expect(evalExpr("globalThis", {})).toBeNull();
});
});
describe("applyCalculations", () => {
const fields: Field[] = [
{ id: "a", type: "number", label: "A" },
{ id: "b", type: "number", label: "B" },
{ id: "sum_ab", type: "calculated", label: "Sum", expression: "a + b" },
{ id: "doubled", type: "calculated", label: "Doubled", expression: "sum_ab * 2" },
];
it("computes and injects calculated values", () => {
const out = applyCalculations(fields, { a: 2, b: 3 });
expect(out.sum_ab).toBe(5);
expect(out.doubled).toBe(10);
});
it("computes downstream calculations after upstream ones (in field order)", () => {
const out = applyCalculations(fields, { a: 10, b: 1 });
expect(out.sum_ab).toBe(11);
expect(out.doubled).toBe(22);
});
it("returns null for calculated fields without an expression", () => {
const f: Field[] = [{ id: "x", type: "calculated", label: "x" }];
expect(applyCalculations(f, {}).x).toBeNull();
});
it("leaves non-calculated fields untouched", () => {
const out = applyCalculations(fields, { a: 1, b: 2, extra: "kept" });
expect(out.extra).toBe("kept");
expect(out.a).toBe(1);
});
});
+217
View File
@@ -0,0 +1,217 @@
// Hand-rolled expression evaluator. No eval, no deps.
// Supported grammar:
// expr := ternary
// ternary := or
// or := and ("||" and)*
// and := compare ("&&" compare)*
// compare := sum ((==|!=|<|>|<=|>=) sum)?
// sum := mul (("+"|"-") mul)*
// mul := unary (("*"|"/"|"%") unary)*
// unary := ("-"|"!") unary | atom
// atom := number | string | ident | call | "(" expr ")"
// call := ident "(" (expr ("," expr)*)? ")"
//
// Identifiers resolve in this order:
// 1. functions: if, sum, avg, min, max, count, len, num
// 2. constants: true, false, null
// 3. vars[id] from values map
import type { Field } from "./types";
type Value = number | string | boolean | null | Value[];
const FUNCTIONS: Record<string, (args: Value[]) => Value> = {
if: ([c, a, b]) => (truthy(c) ? (a ?? null) : (b ?? null)),
sum: (args) => flatNums(args).reduce((a, b) => a + b, 0),
avg: (args) => {
const ns = flatNums(args);
return ns.length === 0 ? 0 : ns.reduce((a, b) => a + b, 0) / ns.length;
},
min: (args) => Math.min(...flatNums(args)),
max: (args) => Math.max(...flatNums(args)),
count: (args) => flatten(args).filter((v) => !empty(v)).length,
len: ([v]) => (typeof v === "string" ? v.length : Array.isArray(v) ? v.length : 0),
num: ([v]) => Number(v ?? 0),
};
function flatten(vs: Value[]): Value[] {
const out: Value[] = [];
for (const v of vs) Array.isArray(v) ? out.push(...flatten(v)) : out.push(v);
return out;
}
function flatNums(vs: Value[]): number[] {
return flatten(vs).map((v) => Number(v)).filter((n) => !Number.isNaN(n));
}
function empty(v: Value): boolean {
return v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
}
function truthy(v: Value): boolean {
if (typeof v === "number") return v !== 0;
if (typeof v === "string") return v.length > 0;
if (Array.isArray(v)) return v.length > 0;
return !!v;
}
// ----- Tokenizer -----
type TokenKind = "num" | "str" | "ident" | "op" | "lparen" | "rparen" | "comma";
type Token = { kind: TokenKind; value: string };
function tokenize(src: string): Token[] {
const out: Token[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === " " || c === "\t" || c === "\n") { i++; continue; }
if (c >= "0" && c <= "9") {
let j = i + 1;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
out.push({ kind: "num", value: src.slice(i, j) }); i = j; continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < src.length && src[j] !== c) j++;
out.push({ kind: "str", value: src.slice(i + 1, j) }); i = j + 1; continue;
}
if (/[A-Za-z_]/.test(c)) {
let j = i + 1;
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
out.push({ kind: "ident", value: src.slice(i, j) }); i = j; continue;
}
if (c === "(") { out.push({ kind: "lparen", value: c }); i++; continue; }
if (c === ")") { out.push({ kind: "rparen", value: c }); i++; continue; }
if (c === ",") { out.push({ kind: "comma", value: c }); i++; continue; }
// Multi-char operators first.
const two = src.slice(i, i + 2);
if (["==","!=","<=",">=","&&","||"].includes(two)) {
out.push({ kind: "op", value: two }); i += 2; continue;
}
if ("+-*/%<>!".includes(c)) { out.push({ kind: "op", value: c }); i++; continue; }
throw new Error(`Unexpected character: ${c}`);
}
return out;
}
// ----- Parser + evaluator (recursive descent) -----
class Parser {
i = 0;
constructor(public tokens: Token[], public vars: Record<string, Value>) {}
peek(): Token | undefined { return this.tokens[this.i]; }
eat(): Token { return this.tokens[this.i++]; }
expect(kind: TokenKind, value?: string): Token {
const t = this.eat();
if (!t || t.kind !== kind || (value && t.value !== value)) throw new Error(`Expected ${kind}${value ? " " + value : ""}`);
return t;
}
parseExpr(): Value { return this.parseOr(); }
parseOr(): Value {
let l = this.parseAnd();
while (this.peek()?.kind === "op" && this.peek()!.value === "||") {
this.eat(); const r = this.parseAnd();
l = truthy(l) || truthy(r);
}
return l;
}
parseAnd(): Value {
let l = this.parseCompare();
while (this.peek()?.kind === "op" && this.peek()!.value === "&&") {
this.eat(); const r = this.parseCompare();
l = truthy(l) && truthy(r);
}
return l;
}
parseCompare(): Value {
const l = this.parseSum();
const t = this.peek();
if (!t || t.kind !== "op" || !["==","!=","<","<=",">",">="].includes(t.value)) return l;
this.eat();
const r = this.parseSum();
switch (t.value) {
case "==": return l === r;
case "!=": return l !== r;
case "<": return Number(l) < Number(r);
case "<=": return Number(l) <= Number(r);
case ">": return Number(l) > Number(r);
case ">=": return Number(l) >= Number(r);
}
return false;
}
parseSum(): Value {
let l = this.parseMul();
while (this.peek()?.kind === "op" && (this.peek()!.value === "+" || this.peek()!.value === "-")) {
const op = this.eat().value;
const r = this.parseMul();
if (op === "+" && (typeof l === "string" || typeof r === "string")) l = String(l ?? "") + String(r ?? "");
else l = Number(l) + (op === "+" ? 1 : -1) * Number(r);
}
return l;
}
parseMul(): Value {
let l = this.parseUnary();
while (this.peek()?.kind === "op" && ["*","/","%"].includes(this.peek()!.value)) {
const op = this.eat().value;
const r = this.parseUnary();
const a = Number(l); const b = Number(r);
l = op === "*" ? a * b : op === "/" ? (b === 0 ? 0 : a / b) : a % b;
}
return l;
}
parseUnary(): Value {
const t = this.peek();
if (t?.kind === "op" && t.value === "-") { this.eat(); return -Number(this.parseUnary()); }
if (t?.kind === "op" && t.value === "!") { this.eat(); return !truthy(this.parseUnary()); }
return this.parseAtom();
}
parseAtom(): Value {
const t = this.eat();
if (!t) throw new Error("Unexpected end of expression");
if (t.kind === "num") return Number(t.value);
if (t.kind === "str") return t.value;
if (t.kind === "lparen") { const v = this.parseExpr(); this.expect("rparen"); return v; }
if (t.kind === "ident") {
if (this.peek()?.kind === "lparen") {
this.eat();
const args: Value[] = [];
if (this.peek()?.kind !== "rparen") {
args.push(this.parseExpr());
while (this.peek()?.kind === "comma") { this.eat(); args.push(this.parseExpr()); }
}
this.expect("rparen");
const fn = FUNCTIONS[t.value];
if (!fn) throw new Error(`Unknown function: ${t.value}`);
return fn(args);
}
if (t.value === "true") return true;
if (t.value === "false") return false;
if (t.value === "null") return null;
// hasOwnProperty so identifiers like `constructor`, `__proto__`, or
// `toString` don't leak through the prototype chain.
if (!Object.prototype.hasOwnProperty.call(this.vars, t.value)) return null;
const v = this.vars[t.value];
return v === undefined ? null : (v as Value);
}
throw new Error(`Unexpected token: ${t.value}`);
}
}
export function evalExpr(expr: string, values: Record<string, unknown>): Value {
try {
const p = new Parser(tokenize(expr), values as Record<string, Value>);
return p.parseExpr();
} catch {
return null;
}
}
/** Compute and inject calculated field values into the result map. */
export function applyCalculations(fields: Field[], values: Record<string, unknown>): Record<string, unknown> {
const next = { ...values };
for (const f of fields) {
if (f.type !== "calculated") continue;
if (!f.expression) { next[f.id] = null; continue; }
next[f.id] = evalExpr(f.expression, next);
}
return next;
}
+6
View File
@@ -0,0 +1,6 @@
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+8
View File
@@ -0,0 +1,8 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"] });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+30
View File
@@ -0,0 +1,30 @@
import { prisma } from "./db";
import type { Field, FormSettings } from "./types";
export function parseFields(raw: string | null | undefined): Field[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as Field[]) : [];
} catch { return []; }
}
export function parseSettings(raw: string | null | undefined): FormSettings {
if (!raw) return {};
try { return JSON.parse(raw) as FormSettings; } catch { return {}; }
}
export async function canViewResults(formId: string, userId: string, userRole: string) {
if (userRole === "admin") return true;
const form = await prisma.form.findUnique({ where: { id: formId }, select: { ownerId: true } });
if (!form) return false;
if (form.ownerId === userId) return true;
const v = await prisma.formViewer.findUnique({ where: { formId_userId: { formId, userId } } });
return !!v;
}
export async function canEditForm(formId: string, userId: string, userRole: string) {
if (userRole === "admin") return true;
const form = await prisma.form.findUnique({ where: { id: formId }, select: { ownerId: true } });
return !!form && form.ownerId === userId;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { ruleMatches, fieldVisible, visibleFields } from "./logic";
import type { Field, LogicRule } from "./types";
const r = (fieldId: string, op: LogicRule["op"], value?: string | number): LogicRule => ({ fieldId, op, value });
describe("ruleMatches", () => {
it("eq compares as strings (so '5' === 5)", () => {
expect(ruleMatches(r("x", "eq", "5"), { x: 5 })).toBe(true);
expect(ruleMatches(r("x", "eq", "yes"), { x: "yes" })).toBe(true);
expect(ruleMatches(r("x", "eq", "yes"), { x: "no" })).toBe(false);
});
it("neq is the inverse of eq", () => {
expect(ruleMatches(r("x", "neq", "a"), { x: "b" })).toBe(true);
expect(ruleMatches(r("x", "neq", "a"), { x: "a" })).toBe(false);
});
it("contains supports both strings and arrays", () => {
expect(ruleMatches(r("x", "contains", "ello"), { x: "hello" })).toBe(true);
expect(ruleMatches(r("x", "contains", "b"), { x: ["a", "b", "c"] })).toBe(true);
expect(ruleMatches(r("x", "contains", "z"), { x: ["a", "b"] })).toBe(false);
});
it("contains is case-insensitive for strings", () => {
expect(ruleMatches(r("x", "contains", "ELLO"), { x: "hello" })).toBe(true);
});
it("gt and lt compare numerically", () => {
expect(ruleMatches(r("x", "gt", 5), { x: 10 })).toBe(true);
expect(ruleMatches(r("x", "gt", 5), { x: "10" })).toBe(true); // coerces
expect(ruleMatches(r("x", "lt", 5), { x: 3 })).toBe(true);
});
it("empty / not_empty distinguish empty values", () => {
expect(ruleMatches(r("x", "empty"), { x: "" })).toBe(true);
expect(ruleMatches(r("x", "empty"), { x: null })).toBe(true);
expect(ruleMatches(r("x", "empty"), { x: [] })).toBe(true);
expect(ruleMatches(r("x", "empty"), {})).toBe(true);
expect(ruleMatches(r("x", "not_empty"), { x: "ok" })).toBe(true);
expect(ruleMatches(r("x", "not_empty"), { x: 0 })).toBe(true); // 0 is not empty
expect(ruleMatches(r("x", "not_empty"), { x: "" })).toBe(false);
});
});
describe("fieldVisible", () => {
const visibleField = (showIf: Field["showIf"]): Field => ({
id: "target", type: "short_text", label: "Target", showIf,
});
it("returns true when showIf is empty/missing", () => {
expect(fieldVisible({ id: "x", type: "short_text", label: "x" }, {})).toBe(true);
expect(fieldVisible(visibleField([]), {})).toBe(true);
});
it("ANDs rules within a single group", () => {
const f = visibleField([{ rules: [r("a", "eq", "yes"), r("b", "gt", 5)] }]);
expect(fieldVisible(f, { a: "yes", b: 10 })).toBe(true);
expect(fieldVisible(f, { a: "yes", b: 1 })).toBe(false);
expect(fieldVisible(f, { a: "no", b: 10 })).toBe(false);
});
it("ORs across groups", () => {
const f = visibleField([
{ rules: [r("a", "eq", "yes")] },
{ rules: [r("b", "gt", 10)] },
]);
expect(fieldVisible(f, { a: "yes", b: 0 })).toBe(true);
expect(fieldVisible(f, { a: "no", b: 20 })).toBe(true);
expect(fieldVisible(f, { a: "no", b: 0 })).toBe(false);
});
it("auto-upgrades legacy LogicRule[] to a single group", () => {
const f = visibleField([r("a", "eq", "yes")] as unknown as Field["showIf"]);
expect(fieldVisible(f, { a: "yes" })).toBe(true);
expect(fieldVisible(f, { a: "no" })).toBe(false);
});
});
describe("visibleFields", () => {
const fields: Field[] = [
{ id: "color", type: "select", label: "Color", options: [{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }] },
{
id: "redOnly", type: "short_text", label: "Red?",
showIf: [{ rules: [{ fieldId: "color", op: "eq", value: "red" }] }],
},
{
id: "blueOnly", type: "short_text", label: "Blue?",
showIf: [{ rules: [{ fieldId: "color", op: "eq", value: "blue" }] }],
},
];
it("includes only fields whose conditions pass", () => {
const shown = visibleFields(fields, { color: "red" });
expect(shown.map((f) => f.id)).toEqual(["color", "redOnly"]);
});
it("hides both branches when neither matches", () => {
const shown = visibleFields(fields, { color: "green" });
expect(shown.map((f) => f.id)).toEqual(["color"]);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { normalizeShowIf, type Field, type LogicRule } from "./types";
export function ruleMatches(rule: LogicRule, values: Record<string, unknown>): boolean {
const v = values[rule.fieldId];
switch (rule.op) {
case "eq": return String(v ?? "") === String(rule.value ?? "");
case "neq": return String(v ?? "") !== String(rule.value ?? "");
case "contains":
if (Array.isArray(v)) return v.map(String).includes(String(rule.value ?? ""));
return String(v ?? "").toLowerCase().includes(String(rule.value ?? "").toLowerCase());
case "gt": return Number(v) > Number(rule.value);
case "lt": return Number(v) < Number(rule.value);
case "empty": return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
case "not_empty": return !(v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0));
}
}
export function fieldVisible(field: Field, values: Record<string, unknown>): boolean {
const groups = normalizeShowIf(field.showIf);
if (groups.length === 0) return true;
// Visible if ANY group passes (rules within a group are ANDed).
return groups.some((g) => g.rules.every((r) => ruleMatches(r, values)));
}
export function visibleFields(fields: Field[], values: Record<string, unknown>): Field[] {
return fields.filter((f) => fieldVisible(f, values));
}
+549
View File
@@ -0,0 +1,549 @@
// Minimal JSON-RPC 2.0 / MCP server. Tool-only — no resources/prompts.
import { createHash } from "crypto";
import { nanoid } from "nanoid";
import { prisma } from "./db";
import { canEditForm, canViewResults, parseFields, parseSettings } from "./forms";
import { visibleFields } from "./logic";
import { recordAudit } from "./audit";
import { snapshotForm } from "./versions";
import type { Field, FormSettings } from "./types";
const PROTOCOL_VERSION = "2024-11-05";
type Ctx = { userId: string; role: string };
type JsonRpcReq = { jsonrpc: "2.0"; id?: number | string | null; method: string; params?: unknown };
type JsonRpcRes =
| { jsonrpc: "2.0"; id: number | string | null; result: unknown }
| { jsonrpc: "2.0"; id: number | string | null; error: { code: number; message: string; data?: unknown } };
export async function authenticate(authHeader: string | null): Promise<Ctx | null> {
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
const secret = authHeader.slice(7).trim();
const tokenHash = createHash("sha256").update(secret).digest("hex");
const t = await prisma.accessToken.findUnique({
where: { tokenHash },
include: { user: { select: { id: true, role: true } } },
});
if (!t) return null;
await prisma.accessToken.update({ where: { id: t.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return { userId: t.user.id, role: t.user.role };
}
export async function handleRpc(req: JsonRpcReq, ctx: Ctx): Promise<JsonRpcRes | null> {
const id = req.id ?? null;
try {
switch (req.method) {
case "initialize":
return ok(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {}, resources: { subscribe: false } },
serverInfo: { name: "formbuilder", version: "0.2.0" },
});
case "notifications/initialized":
return null;
case "ping":
return ok(id, {});
case "tools/list":
return ok(id, { tools: TOOL_LIST });
case "tools/call": {
const { name, arguments: args } = (req.params ?? {}) as { name: string; arguments?: Record<string, unknown> };
const tool = TOOLS[name];
if (!tool) return err(id, -32601, `Unknown tool: ${name}`);
const text = await tool(ctx, args ?? {});
return ok(id, { content: [{ type: "text", text }] });
}
case "resources/list":
return ok(id, await listResources(ctx));
case "resources/read":
return ok(id, await readResource((req.params as { uri: string }).uri, ctx));
default:
return err(id, -32601, `Unknown method: ${req.method}`);
}
} catch (e) {
return err(id, -32603, e instanceof Error ? e.message : "Internal error");
}
}
function ok(id: JsonRpcRes["id"], result: unknown): JsonRpcRes {
return { jsonrpc: "2.0", id, result };
}
function err(id: JsonRpcRes["id"], code: number, message: string): JsonRpcRes {
return { jsonrpc: "2.0", id, error: { code, message } };
}
// ---------- Tool definitions ----------
const ALL_FIELD_TYPES = ["short_text","long_text","number","email","date","select","multi_select","checkbox","rating","file","signature","page_break","calculated"] as const;
const FIELD_INPUT_SCHEMA = {
type: "object",
properties: {
type: { type: "string", enum: ALL_FIELD_TYPES },
label: { type: "string" },
help: { type: "string" },
required: { type: "boolean" },
placeholder: { type: "string" },
options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" } }, required: ["value","label"] } },
min: { type: "number" }, max: { type: "number" },
accept: { type: "string" }, maxSizeMB: { type: "number" },
expression: { type: "string" },
},
required: ["type","label"],
};
const TOOL_LIST = [
{
name: "list_forms",
description: "List forms visible to the authenticated user. Paginated.",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "Page size, max 100 (default 50)." },
cursor: { type: "string", description: "Opaque cursor returned by a previous call." },
tag: { type: "string", description: "Filter to forms with this tag slug." },
},
additionalProperties: false,
},
},
{
name: "get_form",
description: "Get a form's schema and settings by id or slug.",
inputSchema: {
type: "object",
properties: { id: { type: "string" }, slug: { type: "string" } },
additionalProperties: false,
},
},
{
name: "create_form",
description: "Create a new form. Returns the new form id and slug.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
fields: { type: "array", items: FIELD_INPUT_SCHEMA },
publish: { type: "boolean", description: "Publish immediately (default false)." },
},
required: ["title"],
additionalProperties: false,
},
},
{
name: "update_form",
description: "Update form metadata. Pass only the fields you want to change.",
inputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
published: { type: "boolean" },
settings: { type: "object", additionalProperties: true },
},
required: ["id"],
additionalProperties: false,
},
},
{
name: "delete_form",
description: "Delete a form and all of its responses. Owner or admin only.",
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
},
{
name: "add_field",
description: "Append a field to a form (or insert at index).",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, field: FIELD_INPUT_SCHEMA, index: { type: "number" } },
required: ["formId", "field"],
additionalProperties: false,
},
},
{
name: "remove_field",
description: "Remove a field by id.",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, fieldId: { type: "string" } },
required: ["formId", "fieldId"],
additionalProperties: false,
},
},
{
name: "update_field",
description: "Update properties of an existing field.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" }, fieldId: { type: "string" },
patch: { type: "object", additionalProperties: true },
},
required: ["formId", "fieldId", "patch"],
additionalProperties: false,
},
},
{
name: "reorder_fields",
description: "Set the order of fields. Pass an array of field ids.",
inputSchema: {
type: "object",
properties: { formId: { type: "string" }, order: { type: "array", items: { type: "string" } } },
required: ["formId", "order"],
additionalProperties: false,
},
},
{
name: "list_members",
description: "List workspace members. Admin only.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "set_member_role",
description: "Promote or demote a member. Admin only.",
inputSchema: {
type: "object",
properties: { userId: { type: "string" }, role: { type: "string", enum: ["admin", "member"] } },
required: ["userId", "role"],
additionalProperties: false,
},
},
{
name: "create_access_token",
description: "Mint an MCP token for the current user. Returns the secret once.",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
},
{
name: "list_responses",
description: "List responses for a form. Caller must be owner, admin, or listed viewer. Paginated.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" },
limit: { type: "number", description: "Page size, max 200 (default 50)." },
cursor: { type: "string", description: "Opaque cursor from a previous call." },
},
required: ["formId"],
additionalProperties: false,
},
},
{
name: "get_response",
description: "Get a single response by id.",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "submit_response",
description: "Submit a response to a published form. Field keys may be field ids or labels.",
inputSchema: {
type: "object",
properties: {
formId: { type: "string" }, slug: { type: "string" },
data: { type: "object", additionalProperties: true },
},
required: ["data"],
additionalProperties: false,
},
},
] as const;
const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise<string>> = {
async list_forms(ctx, args) {
const isAdmin = ctx.role === "admin";
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 100);
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
const tagSlug = typeof args.tag === "string" && args.tag ? args.tag : undefined;
const baseWhere = isAdmin
? {}
: { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] };
const where = tagSlug
? { AND: [baseWhere, { tags: { some: { tag: { slug: tagSlug } } } }] }
: baseWhere;
const forms = await prisma.form.findMany({
where,
orderBy: { updatedAt: "desc" },
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
include: { _count: { select: { responses: true } }, tags: { include: { tag: { select: { slug: true, name: true } } } } },
});
const hasMore = forms.length > limit;
const page = hasMore ? forms.slice(0, limit) : forms;
const nextCursor = hasMore ? page[page.length - 1].id : null;
return JSON.stringify({
forms: page.map((f) => ({
id: f.id, slug: f.slug, title: f.title, published: f.published,
responses: f._count.responses, updatedAt: f.updatedAt.toISOString(),
tags: f.tags.map((ft) => ({ slug: ft.tag.slug, name: ft.tag.name })),
})),
nextCursor,
}, null, 2);
},
async get_form(_ctx, args) {
const form = await findForm(args);
if (!form) throw new Error("Form not found");
return JSON.stringify({
id: form.id, slug: form.slug, title: form.title, description: form.description,
published: form.published, fields: parseFields(form.fields),
settings: parseSettings(form.settings),
}, null, 2);
},
async create_form(ctx, args) {
const title = String(args.title ?? "Untitled form");
const description = args.description ? String(args.description) : null;
const rawFields = Array.isArray(args.fields) ? (args.fields as Partial<Field>[]) : [];
// Always generate the id ourselves — never trust an id supplied via MCP.
const fields: Field[] = rawFields.map((f) => ({ ...(f as Field), id: nanoid(8) }));
const settings: FormSettings = { visibility: "workspace" };
const form = await prisma.form.create({
data: {
slug: nanoid(10), title, description, ownerId: ctx.userId,
fields: JSON.stringify(fields), settings: JSON.stringify(settings),
published: args.publish === true,
},
});
await snapshotForm({
formId: form.id,
current: { title: form.title, description: form.description, fields: form.fields, settings: form.settings },
authorId: ctx.userId, force: true,
});
await recordAudit({
actorId: ctx.userId, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title, via: "mcp" },
});
return JSON.stringify({ id: form.id, slug: form.slug, url: `/f/${form.slug}` }, null, 2);
},
async list_responses(ctx, args) {
const formId = String(args.formId);
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const limit = Math.min(Math.max(Number(args.limit ?? 50), 1), 200);
const cursor = typeof args.cursor === "string" && args.cursor ? args.cursor : undefined;
const rows = await prisma.response.findMany({
where: { formId },
orderBy: { createdAt: "desc" },
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
include: { submitter: { select: { email: true, name: true } } },
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1].id : null;
return JSON.stringify({
responses: page.map((r) => ({
id: r.id, at: r.createdAt.toISOString(),
by: r.submitter ? r.submitter.email : null,
data: safeJson(r.data),
})),
nextCursor,
}, null, 2);
},
async get_response(ctx, args) {
const r = await prisma.response.findUnique({ where: { id: String(args.id) } });
if (!r) throw new Error("Not found");
if (!(await canViewResults(r.formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
return JSON.stringify({ id: r.id, formId: r.formId, at: r.createdAt.toISOString(), data: safeJson(r.data) }, null, 2);
},
async update_form(ctx, args) {
const formId = String(args.id);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const data: Record<string, unknown> = {};
if (args.title !== undefined) data.title = String(args.title);
if (args.description !== undefined) data.description = args.description === null ? null : String(args.description);
if (args.published !== undefined) data.published = !!args.published;
if (args.settings !== undefined) {
const cur = await prisma.form.findUnique({ where: { id: formId }, select: { settings: true } });
const merged = { ...parseSettings(cur?.settings ?? "{}"), ...(args.settings as FormSettings) };
data.settings = JSON.stringify(merged);
}
const out = await prisma.form.update({ where: { id: formId }, data, select: { id: true, slug: true, published: true, title: true, description: true, fields: true, settings: true } });
await snapshotForm({
formId, current: { title: out.title, description: out.description, fields: out.fields, settings: out.settings },
authorId: ctx.userId,
});
await recordAudit({
actorId: ctx.userId, action: "form.updated", entityType: "form", entityId: formId, metadata: { via: "mcp" },
});
return JSON.stringify({ id: out.id, slug: out.slug, published: out.published }, null, 2);
},
async delete_form(ctx, args) {
const formId = String(args.id);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
await prisma.form.delete({ where: { id: formId } });
await recordAudit({
actorId: ctx.userId, action: "form.deleted", entityType: "form", entityId: formId, metadata: { via: "mcp" },
});
return JSON.stringify({ ok: true }, null, 2);
},
async add_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields);
const incoming = args.field as Partial<Field>;
const newField: Field = { ...(incoming as Field), id: nanoid(8) };
const idx = typeof args.index === "number" ? Math.max(0, Math.min(fields.length, args.index)) : fields.length;
fields.splice(idx, 0, newField);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ id: newField.id }, null, 2);
},
async remove_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields).filter((f) => f.id !== args.fieldId);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ ok: true }, null, 2);
},
async update_field(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const fields = parseFields(form.fields).map((f) => f.id === args.fieldId ? { ...f, ...(args.patch as Partial<Field>) } : f);
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(fields) } });
return JSON.stringify({ ok: true }, null, 2);
},
async reorder_fields(ctx, args) {
const formId = String(args.formId);
if (!(await canEditForm(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const form = await prisma.form.findUniqueOrThrow({ where: { id: formId }, select: { fields: true } });
const order = (args.order as string[]) ?? [];
const fields = parseFields(form.fields);
const map = new Map(fields.map((f) => [f.id, f]));
const ordered: Field[] = [];
for (const id of order) { const f = map.get(id); if (f) { ordered.push(f); map.delete(id); } }
for (const f of map.values()) ordered.push(f); // append any unspecified
await prisma.form.update({ where: { id: formId }, data: { fields: JSON.stringify(ordered) } });
return JSON.stringify({ ok: true }, null, 2);
},
async list_members(ctx) {
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
const members = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return JSON.stringify(members.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })), null, 2);
},
async set_member_role(ctx, args) {
if (ctx.role !== "admin") throw new Error("Forbidden — admin only");
const role = args.role === "admin" ? "admin" : "member";
await prisma.user.update({ where: { id: String(args.userId) }, data: { role } });
return JSON.stringify({ ok: true }, null, 2);
},
async create_access_token(ctx, args) {
const { createHash, randomBytes } = await import("node:crypto");
const secret = `fb_${randomBytes(24).toString("hex")}`;
const tokenHash = createHash("sha256").update(secret).digest("hex");
const prefix = secret.slice(0, 10);
const t = await prisma.accessToken.create({
data: { userId: ctx.userId, name: String(args.name ?? "MCP token"), tokenHash, prefix },
});
await recordAudit({
actorId: ctx.userId, action: "token.created", entityType: "token", entityId: t.id,
metadata: { name: t.name, via: "mcp" },
});
return JSON.stringify({ secret, note: "Copy now — it won't be shown again." }, null, 2);
},
async submit_response(ctx, args) {
const form = await findForm(args);
if (!form || !form.published) throw new Error("Form not available");
const fields = parseFields(form.fields);
// Allow callers to use either field ids OR labels as keys.
const incoming = (args.data ?? {}) as Record<string, unknown>;
const byId: Record<string, unknown> = {};
for (const f of fields) {
if (f.id in incoming) byId[f.id] = incoming[f.id];
else if (f.label in incoming) byId[f.id] = incoming[f.label];
}
const shown = visibleFields(fields, byId);
for (const f of shown) {
const v = byId[f.id];
const empty = v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
if (f.required && empty) throw new Error(`Missing: ${f.label}`);
}
const r = await prisma.response.create({
data: { formId: form.id, submitterId: ctx.userId, data: JSON.stringify(byId) },
});
return JSON.stringify({ id: r.id }, null, 2);
},
};
async function findForm(args: Record<string, unknown>) {
if (args.id) return prisma.form.findUnique({ where: { id: String(args.id) } });
if (args.slug) return prisma.form.findUnique({ where: { slug: String(args.slug) } });
return null;
}
function safeJson(s: string): unknown {
try { return JSON.parse(s); } catch { return s; }
}
// ---------- Resources ----------
//
// formbuilder://forms/{id} → full form schema
// formbuilder://forms/{id}/responses → recent responses (capped at 200)
async function listResources(ctx: Ctx) {
const isAdmin = ctx.role === "admin";
const forms = await prisma.form.findMany({
where: isAdmin ? {} : { OR: [{ ownerId: ctx.userId }, { viewers: { some: { userId: ctx.userId } } }, { published: true }] },
orderBy: { updatedAt: "desc" },
select: { id: true, slug: true, title: true },
});
const resources = forms.flatMap((f) => [
{ uri: `formbuilder://forms/${f.id}`, name: f.title, mimeType: "application/json", description: `Form schema (${f.slug})` },
{ uri: `formbuilder://forms/${f.id}/responses`, name: `${f.title} — responses`, mimeType: "application/json" },
]);
return { resources };
}
async function readResource(uri: string, ctx: Ctx) {
const m = uri.match(/^formbuilder:\/\/forms\/([^/]+)(?:\/(responses))?$/);
if (!m) throw new Error(`Unknown resource: ${uri}`);
const formId = m[1];
const kind = m[2];
if (kind === "responses") {
if (!(await canViewResults(formId, ctx.userId, ctx.role))) throw new Error("Forbidden");
const responses = await prisma.response.findMany({
where: { formId }, orderBy: { createdAt: "desc" }, take: 200,
include: { submitter: { select: { email: true } } },
});
const body = responses.map((r) => ({
id: r.id, at: r.createdAt.toISOString(), by: r.submitter?.email ?? null, data: safeJson(r.data),
}));
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
}
const form = await prisma.form.findUnique({ where: { id: formId } });
if (!form) throw new Error("Not found");
const body = {
id: form.id, slug: form.slug, title: form.title, description: form.description,
published: form.published, fields: parseFields(form.fields), settings: parseSettings(form.settings),
};
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
}
+48
View File
@@ -0,0 +1,48 @@
// Email notification driver. Three modes:
// EMAIL_DRIVER=resend → uses RESEND_API_KEY (recommended, no node deps)
// EMAIL_DRIVER=smtp → uses SMTP_URL via nodemailer (install nodemailer first)
// EMAIL_DRIVER=none → swallow silently; sendEmail() returns immediately
// EMAIL_FROM is required for non-"none" drivers.
type SendArgs = { to: string | string[]; subject: string; html: string; text?: string };
export async function sendEmail(args: SendArgs): Promise<{ ok: boolean; error?: string }> {
const driver = (process.env.EMAIL_DRIVER || "none").toLowerCase();
if (driver === "none") return { ok: true };
const from = process.env.EMAIL_FROM;
if (!from) return { ok: false, error: "EMAIL_FROM not set" };
const to = Array.isArray(args.to) ? args.to : [args.to];
try {
if (driver === "resend") {
const key = process.env.RESEND_API_KEY;
if (!key) return { ok: false, error: "RESEND_API_KEY not set" };
const r = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({ from, to, subject: args.subject, html: args.html, text: args.text }),
});
if (!r.ok) return { ok: false, error: `Resend ${r.status}: ${await r.text().catch(() => "")}` };
return { ok: true };
}
if (driver === "smtp") {
const url = process.env.SMTP_URL;
if (!url) return { ok: false, error: "SMTP_URL not set" };
// Webpack: don't try to resolve nodemailer at build time — it's only
// required when SMTP is actually configured. Users on SMTP must `npm i nodemailer`.
// The indirection through a variable hides the literal from TypeScript so
// the bare `import "nodemailer"` isn't required to resolve at typecheck time.
const mod = "nodemailer";
const nodemailer = await import(/* webpackIgnore: true */ mod).catch(() => null);
if (!nodemailer) return { ok: false, error: "Install nodemailer to use SMTP driver" };
const transporter = (nodemailer as { default: { createTransport: (u: string) => { sendMail: (o: Record<string, unknown>) => Promise<unknown> } } }).default.createTransport(url);
await transporter.sendMail({ from, to, subject: args.subject, html: args.html, text: args.text });
return { ok: true };
}
return { ok: false, error: `Unknown EMAIL_DRIVER: ${driver}` };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "send failed" };
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { pipe } from "./pipe";
import type { Field } from "./types";
const fields: Field[] = [
{ id: "name", type: "short_text", label: "Your name" },
{ id: "email", type: "email", label: "Email Address" },
{ id: "items", type: "multi_select", label: "Items" },
];
describe("pipe", () => {
it("replaces id-keyed tokens", () => {
const out = pipe("Hi {{name}}", { name: "Ada" }, fields);
expect(out).toBe("Hi Ada");
});
it("replaces label-keyed tokens (case-insensitive)", () => {
const out = pipe("Hi {{Your name}}!", { name: "Ada" }, fields);
expect(out).toBe("Hi Ada!");
});
it("collapses missing tokens to empty string (never undefined)", () => {
const out = pipe("Hi {{unknown}}!", {}, fields);
expect(out).toBe("Hi !");
});
it("tolerates whitespace inside the braces", () => {
expect(pipe("{{ name }}", { name: "x" }, fields)).toBe("x");
});
it("joins arrays with commas", () => {
const out = pipe("Picked {{items}}", { items: ["a", "b", "c"] }, fields);
expect(out).toBe("Picked a, b, c");
});
it("returns the template unchanged when there are no tokens", () => {
expect(pipe("plain text", { name: "x" }, fields)).toBe("plain text");
});
it("handles null/undefined values gracefully", () => {
expect(pipe("{{name}}", { name: null }, fields)).toBe("");
expect(pipe("{{email}}", { email: undefined }, fields)).toBe("");
});
it("does not infinitely recurse on token-looking values", () => {
const out = pipe("{{name}}", { name: "{{email}}" }, fields);
expect(out).toBe("{{email}}"); // tokens are replaced once, not recursively
});
});
+27
View File
@@ -0,0 +1,27 @@
// Answer piping. Replaces {{Label}} or {{field_id}} tokens in text with current
// field values. Used in field labels/help, settings.thanksMessage, and the
// redirect URL.
import type { Field } from "./types";
const TOKEN = /\{\{\s*([^}]+?)\s*\}\}/g;
export function pipe(template: string, values: Record<string, unknown>, fields: Field[]): string {
if (!template) return template;
// Build a name-to-id lookup once. Try id-as-key first, then label.
const labelToId = new Map<string, string>();
for (const f of fields) {
labelToId.set(f.label.toLowerCase(), f.id);
}
return template.replace(TOKEN, (_, raw) => {
const key = String(raw).trim();
let v = values[key];
if (v === undefined) {
const id = labelToId.get(key.toLowerCase());
if (id !== undefined) v = values[id];
}
if (v === undefined || v === null) return "";
if (Array.isArray(v)) return v.join(", ");
return String(v);
});
}
+71
View File
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consume, _resetMemoryBuckets, clientIp } from "./ratelimit";
describe("memory rate limiter", () => {
beforeEach(() => {
_resetMemoryBuckets();
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
});
afterEach(() => {
vi.useRealTimers();
});
it("allows requests under the limit", async () => {
const r1 = await consume("k", 3, 1000);
const r2 = await consume("k", 3, 1000);
const r3 = await consume("k", 3, 1000);
expect(r1.ok).toBe(true);
expect(r2.ok).toBe(true);
expect(r3.ok).toBe(true);
expect(r3.remaining).toBe(0);
});
it("rejects the request that exceeds the limit", async () => {
await consume("k", 2, 1000);
await consume("k", 2, 1000);
const r3 = await consume("k", 2, 1000);
expect(r3.ok).toBe(false);
expect(r3.remaining).toBe(0);
});
it("resets the bucket after the window elapses", async () => {
await consume("k", 1, 1000);
const blocked = await consume("k", 1, 1000);
expect(blocked.ok).toBe(false);
vi.advanceTimersByTime(1001);
const allowed = await consume("k", 1, 1000);
expect(allowed.ok).toBe(true);
});
it("isolates buckets by key", async () => {
await consume("a", 1, 1000);
const a2 = await consume("a", 1, 1000);
const b1 = await consume("b", 1, 1000);
expect(a2.ok).toBe(false);
expect(b1.ok).toBe(true);
});
it("returns a sensible resetAt timestamp", async () => {
const r = await consume("k", 5, 1000);
expect(r.resetAt).toBe(1000);
});
});
describe("clientIp", () => {
it("trusts the first hop in x-forwarded-for", () => {
const h = new Headers({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" });
expect(clientIp(h)).toBe("1.2.3.4");
});
it("falls back to x-real-ip", () => {
const h = new Headers({ "x-real-ip": "9.9.9.9" });
expect(clientIp(h)).toBe("9.9.9.9");
});
it("returns 'unknown' when no header is present", () => {
expect(clientIp(new Headers())).toBe("unknown");
});
});
+123
View File
@@ -0,0 +1,123 @@
// Sliding-window rate limiter with pluggable drivers.
//
// RATE_LIMIT_DRIVER=memory (default) — in-process bucket, single-instance only
// RATE_LIMIT_DRIVER=redis — requires REDIS_URL, multi-instance safe
//
// Both drivers expose the same {ok, remaining, resetAt} contract.
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
const MAX_BUCKETS = 10_000;
function sweep(now: number) {
if (buckets.size <= MAX_BUCKETS) return;
for (const [k, b] of buckets) {
if (b.resetAt <= now) buckets.delete(k);
}
}
function memoryConsume(key: string, limit: number, windowMs: number): ConsumeResult {
const now = Date.now();
const b = buckets.get(key);
if (!b || b.resetAt <= now) {
const resetAt = now + windowMs;
buckets.set(key, { count: 1, resetAt });
sweep(now);
return { ok: true, remaining: limit - 1, resetAt };
}
if (b.count >= limit) return { ok: false, remaining: 0, resetAt: b.resetAt };
b.count += 1;
return { ok: true, remaining: limit - b.count, resetAt: b.resetAt };
}
// ---------- Redis driver ----------
// Sorted-set window: ZADD ts ts; ZREMRANGEBYSCORE 0 (now-window); ZCARD; EXPIRE.
// Pipelined in a single round-trip.
type RedisClient = {
multi: () => RedisClient;
zadd: (key: string, score: number, member: string) => RedisClient;
zremrangebyscore: (key: string, min: number, max: number) => RedisClient;
zcard: (key: string) => RedisClient;
pexpire: (key: string, ms: number) => RedisClient;
exec: () => Promise<Array<[Error | null, unknown]> | null>;
};
let redisClient: RedisClient | null = null;
let redisInitPromise: Promise<RedisClient | null> | null = null;
async function getRedis(): Promise<RedisClient | null> {
if (redisClient) return redisClient;
if (!process.env.REDIS_URL) return null;
if (!redisInitPromise) {
redisInitPromise = (async () => {
try {
// Dynamic import so memory-driver users don't pay the cost.
const mod = await import(/* webpackIgnore: true */ "ioredis").catch(() => null);
if (!mod) return null;
// ioredis exports both `default` (CJS) and named `Redis`. Either is a
// constructor that accepts a URL string (the rest of its overloads
// aren't relevant to us, so we cast to a narrow shape).
type Ctor = new (url: string) => RedisClient;
const ModuleShape = mod as unknown as { default?: Ctor; Redis?: Ctor };
const RedisCtor = ModuleShape.default ?? ModuleShape.Redis;
if (!RedisCtor) return null;
redisClient = new RedisCtor(process.env.REDIS_URL!);
return redisClient;
} catch {
return null;
}
})();
}
return redisInitPromise;
}
async function redisConsume(key: string, limit: number, windowMs: number): Promise<ConsumeResult> {
const client = await getRedis();
if (!client) return memoryConsume(key, limit, windowMs); // graceful fallback
const now = Date.now();
const cutoff = now - windowMs;
const member = `${now}:${Math.random().toString(36).slice(2, 8)}`;
try {
const res = await client
.multi()
.zadd(`rl:${key}`, now, member)
.zremrangebyscore(`rl:${key}`, 0, cutoff)
.zcard(`rl:${key}`)
.pexpire(`rl:${key}`, windowMs)
.exec();
const count = res ? Number(res[2]?.[1] ?? 0) : 0;
const ok = count <= limit;
return { ok, remaining: Math.max(0, limit - count), resetAt: now + windowMs };
} catch {
// Redis blip — fail open by falling back to memory for this call.
return memoryConsume(key, limit, windowMs);
}
}
export type ConsumeResult = { ok: boolean; remaining: number; resetAt: number };
export async function consume(
key: string,
limit: number,
windowMs: number,
): Promise<ConsumeResult> {
const driver = (process.env.RATE_LIMIT_DRIVER || "memory").toLowerCase();
if (driver === "redis") return redisConsume(key, limit, windowMs);
return memoryConsume(key, limit, windowMs);
}
// Extract a client IP from a Next.js Request's headers.
// Trusts the first hop in x-forwarded-for (typical for reverse-proxy setups
// like nginx, Cloudflare, or fly.io). Falls back to x-real-ip, then "unknown".
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
return headers.get("x-real-ip") ?? "unknown";
}
// Test hook — drop the in-memory bucket state between unit-test runs.
export function _resetMemoryBuckets() {
buckets.clear();
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { escapeHtml, stripTags, isSafeUrl } from "./sanitize";
describe("escapeHtml", () => {
it("escapes the canonical XSS characters", () => {
expect(escapeHtml("<script>alert('xss')</script>"))
.toBe("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
});
it("escapes &, <, >, \", '", () => {
expect(escapeHtml('a & b < c > d "e" \'f\'')).toBe("a &amp; b &lt; c &gt; d &quot;e&quot; &#39;f&#39;");
});
it("handles null/undefined as empty string", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
it("leaves plain text untouched", () => {
expect(escapeHtml("hello world")).toBe("hello world");
});
});
describe("stripTags", () => {
it("removes simple tags", () => {
expect(stripTags("<b>bold</b> and <i>italic</i>")).toBe("bold and italic");
});
it("strips attributes-laden tags", () => {
expect(stripTags('<a href="x" onclick="y">link</a>')).toBe("link");
});
it("handles malformed input without throwing", () => {
expect(stripTags("<not a tag")).toBe("<not a tag"); // no closing > means nothing to strip
});
});
describe("isSafeUrl", () => {
it("accepts http and https", () => {
expect(isSafeUrl("http://example.com")).toBe(true);
expect(isSafeUrl("https://example.com/path?q=1")).toBe(true);
});
it("rejects javascript: and data: schemes", () => {
expect(isSafeUrl("javascript:alert(1)")).toBe(false);
expect(isSafeUrl("data:text/html,<script>alert(1)</script>")).toBe(false);
});
it("rejects invalid URLs", () => {
expect(isSafeUrl("not a url")).toBe(false);
expect(isSafeUrl("")).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
// Centralized text-to-HTML escaping. Use everywhere user-provided strings flow
// into an HTML context (emails, raw markup, etc). React's JSX rendering escapes
// by default; this is for the non-JSX paths.
const REPLACE: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(input: unknown): string {
if (input === null || input === undefined) return "";
return String(input).replace(/[&<>"']/g, (c) => REPLACE[c] ?? c);
}
// Strip everything that smells like markup. Use for places where the value
// should be plain text but might be rendered raw (mailer subject lines, etc.).
export function stripTags(input: unknown): string {
return String(input ?? "").replace(/<[^>]*>/g, "");
}
// Quick allowlist URL check — refuses anything that isn't http/https. Used on
// the redirect-after-submit setting and on webhook URLs.
export function isSafeUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
// Storage driver abstraction. Two drivers today:
// STORAGE_DRIVER=local (default) — writes under ./uploads/<key>
// STORAGE_DRIVER=s3 — uses @aws-sdk/client-s3 (install when switching)
//
// The contract is intentionally small: write a buffer with a key, read it back
// as a Node Readable, and check existence. Each UploadedFile row records which
// driver wrote the object so old files keep reading correctly after a driver swap.
import { promises as fs, createReadStream } from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
export type StorageDriver = "local" | "s3";
export function currentDriver(): StorageDriver {
const d = (process.env.STORAGE_DRIVER || "local").toLowerCase();
return d === "s3" ? "s3" : "local";
}
export interface ObjectMeta { size: number; contentType: string }
export interface StorageDriverImpl {
put(key: string, body: Buffer, meta: ObjectMeta): Promise<void>;
read(key: string): Promise<Readable>;
exists(key: string): Promise<boolean>;
remove(key: string): Promise<void>;
}
// ---------- Local driver ----------
const LOCAL_ROOT = path.resolve(process.cwd(), process.env.UPLOAD_DIR || "uploads");
async function ensureLocalRoot() {
await fs.mkdir(LOCAL_ROOT, { recursive: true });
}
function localPath(key: string) {
// Disallow path traversal — keys must be plain segments only.
const safe = key.replace(/[^a-zA-Z0-9_\-./]/g, "");
return path.join(LOCAL_ROOT, safe);
}
const local: StorageDriverImpl = {
async put(key, body) {
await ensureLocalRoot();
const p = localPath(key);
await fs.mkdir(path.dirname(p), { recursive: true });
await fs.writeFile(p, body);
},
async read(key) {
return createReadStream(localPath(key));
},
async exists(key) {
try { await fs.access(localPath(key)); return true; } catch { return false; }
},
async remove(key) {
await fs.unlink(localPath(key)).catch(() => {});
},
};
// ---------- S3 driver (stub — wire when needed) ----------
const s3: StorageDriverImpl = {
async put() { throw new Error("S3 driver not wired. Install @aws-sdk/client-s3 and implement put()."); },
async read() { throw new Error("S3 driver not wired."); },
async exists() { return false; },
async remove() { /* noop */ },
};
const drivers: Record<StorageDriver, StorageDriverImpl> = { local, s3 };
export function driver(name?: StorageDriver): StorageDriverImpl {
return drivers[name ?? currentDriver()];
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { slugifyTag } from "./tags";
describe("slugifyTag", () => {
it("lowercases and replaces spaces with hyphens", () => {
expect(slugifyTag("Customer Feedback")).toBe("customer-feedback");
});
it("strips non-alphanumeric characters", () => {
expect(slugifyTag("HR / Hiring!")).toBe("hr-hiring");
});
it("trims leading/trailing hyphens", () => {
expect(slugifyTag(" --internal-- ")).toBe("internal");
});
it("collapses to empty when input has nothing slug-worthy", () => {
expect(slugifyTag("@@@@")).toBe("");
expect(slugifyTag(" ")).toBe("");
});
it("clips length at 40 chars", () => {
expect(slugifyTag("a".repeat(80)).length).toBe(40);
});
});
+66
View File
@@ -0,0 +1,66 @@
// Lightweight tag helpers. Tags are global to the workspace — there's only one
// team, so we don't scope them. A tag is created on first use; deleting it is
// an explicit operation in the tag manager.
import { prisma } from "./db";
const SLUG_RE = /[^a-z0-9-]+/g;
export function slugifyTag(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(SLUG_RE, "")
.replace(/-+/g, "-") // collapse adjacent hyphens from stripped punctuation
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
// Find or create tags by name. Returns the canonical Tag rows.
export async function upsertTagsByName(names: string[]): Promise<Array<{ id: string; slug: string; name: string }>> {
const out: Array<{ id: string; slug: string; name: string }> = [];
for (const raw of names) {
const name = raw.trim();
if (!name) continue;
const slug = slugifyTag(name);
if (!slug) continue;
const row = await prisma.tag.upsert({
where: { slug },
create: { slug, name },
update: {}, // Don't overwrite display name on re-use.
select: { id: true, slug: true, name: true },
});
out.push(row);
}
return out;
}
export async function setFormTags(formId: string, tagNames: string[]): Promise<void> {
const tags = await upsertTagsByName(tagNames);
await prisma.$transaction([
prisma.formTag.deleteMany({ where: { formId } }),
prisma.formTag.createMany({
data: tags.map((t) => ({ formId, tagId: t.id })),
skipDuplicates: true,
}),
]);
}
export async function listAllTags() {
// Tags ordered by usage count desc, then name asc.
const rows = await prisma.tag.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { forms: true } } },
});
return rows
.map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color, count: t._count.forms }))
.sort((a, b) => (b.count - a.count) || a.name.localeCompare(b.name));
}
export async function deleteTagIfUnused(tagId: string): Promise<boolean> {
const count = await prisma.formTag.count({ where: { tagId } });
if (count > 0) return false;
await prisma.tag.delete({ where: { id: tagId } }).catch(() => {});
return true;
}
+131
View File
@@ -0,0 +1,131 @@
// Starter templates. Each is a partial Form — title, description, fields,
// settings. Cloned into a new row by createFromTemplate().
import type { Field, FormSettings } from "./types";
export type Template = {
id: string;
name: string;
emoji: string;
description: string;
form: { title: string; description?: string; fields: Field[]; settings: FormSettings };
};
const fid = (s: string) => s; // template-time ids; nanoid'd at clone time
export const TEMPLATES: Template[] = [
{
id: "contact",
name: "Contact",
emoji: "✉️",
description: "Name, email, message — the classic.",
form: {
title: "Get in touch",
description: "We'll reply within a few business days.",
settings: { visibility: "public" },
fields: [
{ id: fid("name"), type: "short_text", label: "Name", required: true },
{ id: fid("email"), type: "email", label: "Email", required: true },
{ id: fid("message"), type: "long_text", label: "Message", required: true, placeholder: "What's on your mind?" },
],
},
},
{
id: "nps",
name: "NPS survey",
emoji: "📊",
description: "Net promoter score with a follow-up.",
form: {
title: "How likely are you to recommend us?",
settings: { visibility: "workspace" },
fields: [
{ id: fid("score"), type: "rating", label: "0 — 10", max: 10, required: true },
{ id: fid("reason"), type: "long_text", label: "What's the main reason for your score?" },
],
},
},
{
id: "rsvp",
name: "Event RSVP",
emoji: "🎟️",
description: "Attending, plus-ones, dietary notes.",
form: {
title: "Will you be there?",
settings: { visibility: "workspace" },
fields: [
{ id: fid("name"), type: "short_text", label: "Your name", required: true },
{
id: fid("attending"), type: "select", label: "Attending?", required: true,
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ value: "maybe", label: "Maybe" },
],
},
{
id: fid("guests"), type: "number", label: "Plus ones", min: 0, max: 5,
showIf: [{ rules: [{ fieldId: fid("attending"), op: "eq", value: "yes" }] }],
},
{
id: fid("diet"), type: "long_text", label: "Dietary notes",
showIf: [{ rules: [{ fieldId: fid("attending"), op: "eq", value: "yes" }] }],
},
],
},
},
{
id: "job",
name: "Job application",
emoji: "💼",
description: "Quick intake for a role posting.",
form: {
title: "Apply",
description: "Tell us about yourself and we'll get back to you.",
settings: { visibility: "public" },
fields: [
{ id: fid("name"), type: "short_text", label: "Full name", required: true },
{ id: fid("email"), type: "email", label: "Email", required: true },
{ id: fid("link"), type: "short_text", label: "Portfolio / LinkedIn" },
{
id: fid("role"), type: "select", label: "Role", required: true,
options: [
{ value: "eng", label: "Engineering" },
{ value: "design", label: "Design" },
{ value: "ops", label: "Operations" },
{ value: "other", label: "Other" },
],
},
{ id: fid("why"), type: "long_text", label: "Why this role?", required: true },
],
},
},
{
id: "bug",
name: "Bug report",
emoji: "🐛",
description: "Repro steps, severity, expected vs actual.",
form: {
title: "Report a bug",
settings: { visibility: "workspace" },
fields: [
{ id: fid("title"), type: "short_text", label: "What went wrong?", required: true },
{
id: fid("severity"), type: "select", label: "Severity", required: true,
options: [
{ value: "low", label: "Low" },
{ value: "med", label: "Medium" },
{ value: "high", label: "High" },
{ value: "blocker", label: "Blocker" },
],
},
{ id: fid("steps"), type: "long_text", label: "Steps to reproduce", required: true },
{ id: fid("expected"), type: "long_text", label: "Expected" },
{ id: fid("actual"), type: "long_text", label: "Actual" },
],
},
},
];
export function getTemplate(id: string): Template | undefined {
return TEMPLATES.find((t) => t.id === id);
}
+111
View File
@@ -0,0 +1,111 @@
// Shared types stored as JSON in DB.
export type FieldType =
| "short_text"
| "long_text"
| "number"
| "email"
| "date"
| "select"
| "multi_select"
| "checkbox"
| "rating"
| "file"
| "page_break"
| "calculated"
| "signature";
export type FormLayout = "one_page" | "paged" | "one_at_a_time";
/** A reference to an uploaded file, stored in response.data for file fields. */
export type FileRef = { id: string; name: string; size: number; contentType: string };
export type LogicOp =
| "eq"
| "neq"
| "contains"
| "gt"
| "lt"
| "empty"
| "not_empty";
export type LogicRule = {
fieldId: string;
op: LogicOp;
value?: string | number;
};
// Rules within a group are ANDed; field is visible if ANY group passes.
// Single-group means strict AND (the original behavior). Multiple groups = OR.
export type LogicGroup = { rules: LogicRule[] };
export type FieldOption = { value: string; label: string };
export type Field = {
id: string;
type: FieldType;
label: string;
help?: string;
required?: boolean;
placeholder?: string;
options?: FieldOption[];
min?: number;
max?: number;
/** File-only: comma-separated MIME types or extensions (e.g. "image/*,.pdf"). */
accept?: string;
/** File-only: max file size in MB. */
maxSizeMB?: number;
/** Calculated-only: expression evaluated against other field values. */
expression?: string;
// Visibility rules. The new shape is LogicGroup[] (groups ORed). The legacy
// shape LogicRule[] is auto-upgraded by normalizeShowIf().
showIf?: LogicGroup[] | LogicRule[];
};
export type FormVisibility = "workspace" | "public";
export type FormSettings = {
visibility?: FormVisibility;
thanksMessage?: string;
accent?: string; // hex
redirectUrl?: string;
/** Email recipients to notify on each new response. */
notifyEmails?: string[];
/** Webhook POSTed to on each new response. Signed with webhookSecret. */
webhookUrl?: string;
webhookSecret?: string;
/** Require hCaptcha on submit (only effective when HCAPTCHA_SITE_KEY is set). */
hcaptchaEnabled?: boolean;
/** Page rendering mode. Defaults to "one_page". */
layout?: FormLayout;
/** UploadedFile id for the cover image (banner at top of form). */
coverImage?: string;
/** UploadedFile id for the logo (inline beside title). */
logo?: string;
/** Curated font family name. See FONT_OPTIONS in the runtime. */
font?: string;
};
/** Coerce either legacy LogicRule[] or new LogicGroup[] into LogicGroup[]. */
export function normalizeShowIf(s: Field["showIf"]): LogicGroup[] {
if (!s || s.length === 0) return [];
// Heuristic: rule items have `fieldId`; group items have `rules`.
if ("fieldId" in (s[0] as object)) return [{ rules: s as LogicRule[] }];
return s as LogicGroup[];
}
export const FIELD_TYPE_LABELS: Record<FieldType, string> = {
short_text: "Short text",
long_text: "Long text",
number: "Number",
email: "Email",
date: "Date",
select: "Single choice",
multi_select: "Multiple choice",
checkbox: "Checkbox",
rating: "Rating",
file: "File upload",
page_break: "Page break",
calculated: "Calculated",
signature: "Signature",
};
+67
View File
@@ -0,0 +1,67 @@
// Form versioning. Every save snapshots the form schema into FormVersion.
// Snapshots are skipped when no meaningful field changed (title/description-only
// edits don't bloat the history). Reverting creates a new version with the old
// content rather than deleting forward history.
import { prisma } from "./db";
type FormShape = {
title: string;
description: string | null;
fields: string;
settings: string;
};
// Returns the version number that was just written, or null if skipped.
export async function snapshotForm(opts: {
formId: string;
current: FormShape;
authorId: string | null;
force?: boolean;
}): Promise<number | null> {
const { formId, current, authorId, force } = opts;
const last = await prisma.formVersion.findFirst({
where: { formId },
orderBy: { version: "desc" },
select: { version: true, fields: true, settings: true, title: true, description: true },
});
if (!force && last) {
const sameSchema = last.fields === current.fields && last.settings === current.settings;
const sameMeta = last.title === current.title && (last.description ?? null) === (current.description ?? null);
if (sameSchema && sameMeta) return null;
}
const nextVersion = (last?.version ?? 0) + 1;
await prisma.formVersion.create({
data: {
formId,
version: nextVersion,
title: current.title,
description: current.description,
fields: current.fields,
settings: current.settings,
authorId,
},
});
return nextVersion;
}
export async function listVersions(formId: string, take = 50) {
return prisma.formVersion.findMany({
where: { formId },
orderBy: { version: "desc" },
take,
select: {
id: true, version: true, title: true,
authorId: true, createdAt: true,
author: { select: { name: true, email: true } },
},
});
}
export async function getVersion(formId: string, version: number) {
return prisma.formVersion.findUnique({
where: { formId_version: { formId, version } },
});
}
+129
View File
@@ -0,0 +1,129 @@
// Outbound webhook delivery. POST JSON, sign with HMAC-SHA256 over `${ts}.${body}`.
// Header: X-FormBuilder-Signature: t=<unix>,v1=<hex>
//
// Delivery model:
// 1. enqueueWebhook() persists a WebhookDelivery row and triggers the first attempt.
// 2. attemptDelivery() does one HTTP try. On a retryable failure (network or 5xx)
// it schedules nextAttemptAt with exponential backoff; on 4xx it gives up.
// 3. processDueWebhooks() is the worker entry point — call it from a cron route.
//
// Backoff: 30s, 2m, 10m, 1h, 6h (5 retries → ~7h total). Stops after MAX_ATTEMPTS.
import { createHmac } from "node:crypto";
import { prisma } from "./db";
export type WebhookPayload = {
event: "response.created";
formId: string;
responseId: string;
data: Record<string, unknown>;
submittedAt: string;
};
const MAX_ATTEMPTS = 6;
const BACKOFF_MS = [30_000, 120_000, 600_000, 3_600_000, 21_600_000];
const TIMEOUT_MS = 5_000;
function nextBackoff(attempts: number): number | null {
if (attempts >= MAX_ATTEMPTS) return null;
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)];
}
function sign(secret: string | null | undefined, body: string, ts: number): string | null {
if (!secret) return null;
return createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
}
async function postOnce(url: string, body: string, secret: string | null | undefined): Promise<{
status: number; error: string | null;
}> {
const ts = Math.floor(Date.now() / 1000);
const sig = sign(secret, body, ts);
const headers: Record<string, string> = {
"content-type": "application/json",
"user-agent": "formbuilder-webhook/2",
};
if (sig) headers["x-formbuilder-signature"] = `t=${ts},v1=${sig}`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { method: "POST", headers, body, signal: ctrl.signal });
return { status: r.status, error: r.ok ? null : `HTTP ${r.status}` };
} catch (e) {
return { status: 0, error: e instanceof Error ? e.message : "fetch error" };
} finally {
clearTimeout(timer);
}
}
// Persist a new delivery and attempt it immediately. Failures schedule a retry
// row; the caller doesn't await retries.
export async function enqueueWebhook(opts: {
url: string;
secret?: string;
payload: WebhookPayload;
}): Promise<void> {
const body = JSON.stringify(opts.payload);
const row = await prisma.webhookDelivery.create({
data: {
formId: opts.payload.formId,
responseId: opts.payload.responseId,
url: opts.url,
payload: body,
secret: opts.secret ?? null,
attempts: 0,
status: 0,
nextAttemptAt: new Date(),
},
}).catch(() => null);
if (!row) return;
await attemptDelivery(row.id);
}
// Make one attempt against a delivery row, updating status/attempts/nextAttemptAt.
// Returns true if delivered successfully, false otherwise.
export async function attemptDelivery(deliveryId: string): Promise<boolean> {
const row = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId } });
if (!row || !row.url) return false;
if (row.status >= 200 && row.status < 300) return true;
const result = await postOnce(row.url, row.payload, row.secret);
const attempts = row.attempts + 1;
const success = result.status >= 200 && result.status < 300;
const isRetryable = !success && (result.status === 0 || result.status >= 500);
const backoff = isRetryable ? nextBackoff(attempts) : null;
const nextAttemptAt = backoff !== null ? new Date(Date.now() + backoff) : null;
await prisma.webhookDelivery.update({
where: { id: deliveryId },
data: {
attempts,
status: result.status,
lastError: success ? null : result.error,
lastAttemptAt: new Date(),
nextAttemptAt,
},
}).catch(() => {});
return success;
}
// Worker entry — drain all deliveries whose nextAttemptAt is due. Cap each
// invocation so a long backlog doesn't tie up the runtime.
export async function processDueWebhooks(maxRows = 50): Promise<{ processed: number; succeeded: number }> {
const due = await prisma.webhookDelivery.findMany({
where: { nextAttemptAt: { not: null, lte: new Date() } },
orderBy: { nextAttemptAt: "asc" },
take: maxRows,
});
let succeeded = 0;
for (const row of due) {
const ok = await attemptDelivery(row.id);
if (ok) succeeded += 1;
}
return { processed: due.length, succeeded };
}
// Back-compat shim — old call sites used deliverWebhook().
export const deliverWebhook = enqueueWebhook;
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export default auth((req) => {
const isApp = req.nextUrl.pathname.startsWith("/app");
if (isApp && !req.auth) {
const url = new URL("/signin", req.url);
url.searchParams.set("from", req.nextUrl.pathname);
return NextResponse.redirect(url);
}
});
export const config = {
matcher: ["/app/:path*"],
};