Prepare Portainer deployment
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
scripts/screenshots
|
||||
scripts
|
||||
tests
|
||||
*.local
|
||||
*.log
|
||||
@@ -0,0 +1,60 @@
|
||||
# --- Portainer / Compose ---
|
||||
APP_PORT="3000"
|
||||
POSTGRES_DB="formbuilder"
|
||||
POSTGRES_USER="formbuilder"
|
||||
POSTGRES_PASSWORD="change-this"
|
||||
|
||||
# --- Auth.js ---
|
||||
# Generate with: openssl rand -base64 32
|
||||
AUTH_SECRET="change-me"
|
||||
# Public origin of the app (no trailing slash).
|
||||
AUTH_URL="https://forms.example.com"
|
||||
|
||||
# --- Authentik OIDC ---
|
||||
# In Authentik: Applications → Providers → Create → OAuth2/OpenID
|
||||
# Redirect URI: ${AUTH_URL}/api/auth/callback/oidc
|
||||
OIDC_ISSUER="https://authentik.example.com/application/o/formbuilder/"
|
||||
OIDC_CLIENT_ID="your-client-id"
|
||||
OIDC_CLIENT_SECRET="your-client-secret"
|
||||
OIDC_PROVIDER_NAME="Authentik"
|
||||
|
||||
# Comma-separated emails auto-promoted to admin on first sign-in.
|
||||
# (The very first user is also auto-promoted to admin.)
|
||||
AUTH_BOOTSTRAP_ADMINS=""
|
||||
|
||||
# --- Rate limiting ---
|
||||
# memory (default, single process) or redis (multi-instance; requires REDIS_URL)
|
||||
RATE_LIMIT_DRIVER="memory"
|
||||
# REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# --- Notifications ---
|
||||
# Defaults to AUTH_URL in docker-compose.yml.
|
||||
PUBLIC_BASE_URL="https://forms.example.com"
|
||||
# Email driver: resend | smtp | none
|
||||
EMAIL_DRIVER="none"
|
||||
EMAIL_FROM="Forms <forms@example.com>"
|
||||
# RESEND_API_KEY="re_xxx"
|
||||
# SMTP_URL="smtp://user:pass@smtp.example.com:587"
|
||||
|
||||
# --- File storage ---
|
||||
# local (default; files under ./uploads/) | s3 (install @aws-sdk/client-s3 first)
|
||||
STORAGE_DRIVER="local"
|
||||
UPLOAD_DIR="uploads"
|
||||
# S3_BUCKET=""
|
||||
# S3_REGION=""
|
||||
# S3_ENDPOINT="" # set for R2 / Minio / custom
|
||||
# S3_ACCESS_KEY_ID=""
|
||||
# S3_SECRET_ACCESS_KEY=""
|
||||
|
||||
# --- hCaptcha (optional) ---
|
||||
# Site key + secret from https://dashboard.hcaptcha.com/
|
||||
# HCAPTCHA_SITE_KEY=""
|
||||
# HCAPTCHA_SECRET=""
|
||||
|
||||
# --- Webhook worker (optional) ---
|
||||
# Shared secret protecting POST /api/webhooks/process — call it from a cron job
|
||||
# every minute to drain pending webhook retries.
|
||||
# Example call:
|
||||
# curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||
# $PUBLIC_BASE_URL/api/webhooks/process
|
||||
# CRON_SECRET=""
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
!.env.example
|
||||
dist
|
||||
out
|
||||
*.log
|
||||
.DS_Store
|
||||
data/
|
||||
uploads/
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV AUTH_SECRET=build-time-placeholder
|
||||
ENV AUTH_URL=http://localhost:3000
|
||||
ENV OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
|
||||
ENV OIDC_CLIENT_ID=build-time-placeholder
|
||||
ENV OIDC_CLIENT_SECRET=build-time-placeholder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY docker/entrypoint.sh ./docker/entrypoint.sh
|
||||
|
||||
RUN chmod +x ./docker/entrypoint.sh
|
||||
|
||||
VOLUME ["/app/uploads"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3000/signin').then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "/app/docker/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# FormBuilder
|
||||
|
||||
Self-hosted form builder for one workspace with Authentik OIDC sign-in, Prisma/Postgres storage, per-form response ACLs, public form links, webhooks, uploads, and an MCP endpoint.
|
||||
|
||||
## Portainer Deployment
|
||||
|
||||
This repo is ready to deploy as a Portainer stack with `docker-compose.yml`.
|
||||
|
||||
1. Create a new git repo and push this directory.
|
||||
2. In Portainer, create a stack from the git repository.
|
||||
3. Copy `.env.example` to `.env` in the stack environment and fill the values below.
|
||||
4. Deploy the stack.
|
||||
|
||||
The app container runs `prisma migrate deploy` before starting the Next.js standalone server.
|
||||
|
||||
## Required `.env`
|
||||
|
||||
```bash
|
||||
APP_PORT=3000
|
||||
POSTGRES_DB=formbuilder
|
||||
POSTGRES_USER=formbuilder
|
||||
POSTGRES_PASSWORD=replace-with-a-strong-password
|
||||
|
||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
AUTH_URL=https://forms.example.com
|
||||
|
||||
OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
|
||||
OIDC_CLIENT_ID=replace-with-authentik-client-id
|
||||
OIDC_CLIENT_SECRET=replace-with-authentik-client-secret
|
||||
OIDC_PROVIDER_NAME=Authentik
|
||||
|
||||
AUTH_BOOTSTRAP_ADMINS=you@example.com
|
||||
```
|
||||
|
||||
Optional values are documented in `.env.example` for Redis rate limiting, email, file storage, hCaptcha, and webhook worker auth.
|
||||
|
||||
## Authentik Setup
|
||||
|
||||
Create an OAuth2/OpenID provider in Authentik:
|
||||
|
||||
- Provider type: OAuth2/OpenID
|
||||
- Client type: Confidential
|
||||
- Redirect URI: `${AUTH_URL}/api/auth/callback/oidc`
|
||||
- Scopes: `openid`, `profile`, `email`
|
||||
- Issuer mode: use the provider's OpenID Configuration Issuer URL
|
||||
|
||||
Then create an Authentik application and bind it to that provider. Put the issuer, client ID, and client secret in `.env`.
|
||||
|
||||
The first successful signer becomes an admin. Any emails listed in `AUTH_BOOTSTRAP_ADMINS` are also promoted on first sign-in.
|
||||
|
||||
## Persistent Data
|
||||
|
||||
The compose stack creates two named volumes:
|
||||
|
||||
- `postgres_data`: Postgres database
|
||||
- `uploads`: local uploaded files mounted at `/app/uploads`
|
||||
|
||||
For multi-instance deployments, set `RATE_LIMIT_DRIVER=redis` and provide `REDIS_URL`. For durable object storage outside the app container, configure the S3 values in `.env.example`.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
npm run build
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## MCP Endpoint
|
||||
|
||||
The MCP endpoint is available at:
|
||||
|
||||
```text
|
||||
POST /api/mcp
|
||||
```
|
||||
|
||||
Create a token in `/app/account`, then send requests with:
|
||||
|
||||
```text
|
||||
Authorization: Bearer fb_xxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
Next.js 15 App Router, React 19, Auth.js v5, Authentik OIDC, Prisma, Postgres, Tailwind, and Docker Compose.
|
||||
@@ -0,0 +1,60 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-formbuilder}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-formbuilder}
|
||||
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET}
|
||||
AUTH_URL: ${AUTH_URL:?set AUTH_URL}
|
||||
PUBLIC_BASE_URL: ${AUTH_URL}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:?set OIDC_ISSUER}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?set OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||
OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-Authentik}
|
||||
AUTH_BOOTSTRAP_ADMINS: ${AUTH_BOOTSTRAP_ADMINS:-}
|
||||
RATE_LIMIT_DRIVER: ${RATE_LIMIT_DRIVER:-memory}
|
||||
REDIS_URL: ${REDIS_URL:-}
|
||||
EMAIL_DRIVER: ${EMAIL_DRIVER:-none}
|
||||
EMAIL_FROM: ${EMAIL_FROM:-Forms <forms@example.com>}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-uploads}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
HCAPTCHA_SITE_KEY: ${HCAPTCHA_SITE_KEY:-}
|
||||
HCAPTCHA_SECRET: ${HCAPTCHA_SECRET:-}
|
||||
CRON_SECRET: ${CRON_SECRET:-}
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-formbuilder}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-formbuilder}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-formbuilder} -d ${POSTGRES_DB:-formbuilder}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads:
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
npx prisma migrate deploy
|
||||
|
||||
exec "$@"
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
experimental: { serverActions: { bodySizeLimit: "2mb" } },
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+4803
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "formbuilder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@dnd-kit/core": "^6.2.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"prisma": "^5.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
"role" TEXT NOT NULL DEFAULT 'member',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Form" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"fields" TEXT NOT NULL DEFAULT '[]',
|
||||
"settings" TEXT NOT NULL DEFAULT '{}',
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Form_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"fields" TEXT NOT NULL,
|
||||
"settings" TEXT NOT NULL,
|
||||
"authorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FormVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormViewer" (
|
||||
"formId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FormViewer_pkey" PRIMARY KEY ("formId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Response" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"submitterId" TEXT,
|
||||
"data" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Response_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"sessionKey" TEXT NOT NULL,
|
||||
"durationMs" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FormEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PartialResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"submitterId" TEXT,
|
||||
"anonKey" TEXT,
|
||||
"data" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PartialResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UploadedFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"responseId" TEXT,
|
||||
"uploaderId" TEXT,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"driver" TEXT NOT NULL DEFAULT 'local',
|
||||
"name" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UploadedFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"responseId" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL DEFAULT '',
|
||||
"status" INTEGER NOT NULL DEFAULT 0,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"nextAttemptAt" TIMESTAMP(3),
|
||||
"lastAttemptAt" TIMESTAMP(3),
|
||||
"payload" TEXT NOT NULL DEFAULT '',
|
||||
"secret" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccessToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormTag" (
|
||||
"formId" TEXT NOT NULL,
|
||||
"tagId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FormTag_pkey" PRIMARY KEY ("formId","tagId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"metadata" TEXT NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Form_slug_key" ON "Form"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormVersion_formId_createdAt_idx" ON "FormVersion"("formId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FormVersion_formId_version_key" ON "FormVersion"("formId", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormEvent_formId_createdAt_idx" ON "FormEvent"("formId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormEvent_formId_kind_idx" ON "FormEvent"("formId", "kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PartialResponse_formId_submitterId_key" ON "PartialResponse"("formId", "submitterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PartialResponse_formId_anonKey_key" ON "PartialResponse"("formId", "anonKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UploadedFile_formId_createdAt_idx" ON "UploadedFile"("formId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_formId_createdAt_idx" ON "WebhookDelivery"("formId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_nextAttemptAt_idx" ON "WebhookDelivery"("nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccessToken_tokenHash_key" ON "AccessToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormTag_tagId_idx" ON "FormTag"("tagId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_actorId_createdAt_idx" ON "AuditLog"("actorId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Form" ADD CONSTRAINT "Form_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormVersion" ADD CONSTRAINT "FormVersion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormVersion" ADD CONSTRAINT "FormVersion_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormViewer" ADD CONSTRAINT "FormViewer_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormViewer" ADD CONSTRAINT "FormViewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Response" ADD CONSTRAINT "Response_submitterId_fkey" FOREIGN KEY ("submitterId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormEvent" ADD CONSTRAINT "FormEvent_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PartialResponse" ADD CONSTRAINT "PartialResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PartialResponse" ADD CONSTRAINT "PartialResponse_submitterId_fkey" FOREIGN KEY ("submitterId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormTag" ADD CONSTRAINT "FormTag_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormTag" ADD CONSTRAINT "FormTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// One workspace, one team. No multi-tenancy.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ---------- Auth.js (Prisma adapter) ----------
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
role String @default("member") // "admin" | "member"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
forms Form[] @relation("FormOwner")
|
||||
responses Response[]
|
||||
viewers FormViewer[]
|
||||
tokens AccessToken[]
|
||||
uploads UploadedFile[]
|
||||
partials PartialResponse[]
|
||||
auditLogs AuditLog[]
|
||||
formVersions FormVersion[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
// ---------- Forms ----------
|
||||
|
||||
model Form {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
description String?
|
||||
ownerId String
|
||||
owner User @relation("FormOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Field schema stored as JSON: Field[] (see src/lib/types.ts)
|
||||
fields String @default("[]")
|
||||
// Form-level settings JSON: { visibility, thanksMessage, theme }
|
||||
settings String @default("{}")
|
||||
published Boolean @default(false)
|
||||
|
||||
responses Response[]
|
||||
viewers FormViewer[]
|
||||
uploads UploadedFile[]
|
||||
partials PartialResponse[]
|
||||
events FormEvent[]
|
||||
versions FormVersion[]
|
||||
tags FormTag[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Immutable snapshot of a form's schema for revert/diff. One row per save.
|
||||
model FormVersion {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
// 1-indexed, monotonically increasing per form.
|
||||
version Int
|
||||
title String
|
||||
description String?
|
||||
fields String
|
||||
settings String
|
||||
// The user who saved this version (may be null if author was deleted).
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([formId, version])
|
||||
@@index([formId, createdAt])
|
||||
}
|
||||
|
||||
// Who, besides owner & admins, can view responses.
|
||||
model FormViewer {
|
||||
formId String
|
||||
userId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@id([formId, userId])
|
||||
}
|
||||
|
||||
model Response {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
submitterId String?
|
||||
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
|
||||
// JSON: Record<fieldId, value>
|
||||
data String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model FormEvent {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
// "view" | "start" | "submit" | "abandon"
|
||||
kind String
|
||||
sessionKey String
|
||||
durationMs Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([formId, createdAt])
|
||||
@@index([formId, kind])
|
||||
}
|
||||
|
||||
model PartialResponse {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
submitterId String?
|
||||
submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull)
|
||||
anonKey String?
|
||||
data String
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Nulls are distinct in Postgres, so multiple rows with one side null
|
||||
// can coexist — that's intended (one row per (form, identity)).
|
||||
@@unique([formId, submitterId])
|
||||
@@unique([formId, anonKey])
|
||||
}
|
||||
|
||||
model UploadedFile {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
responseId String?
|
||||
uploaderId String?
|
||||
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
|
||||
// Storage key — opaque path/key in the configured driver.
|
||||
storageKey String
|
||||
// Storage driver that wrote this object (so we can read it back later
|
||||
// even if the default is later switched).
|
||||
driver String @default("local")
|
||||
name String
|
||||
size Int
|
||||
contentType String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([formId, createdAt])
|
||||
}
|
||||
|
||||
// Outbound webhook delivery record. One row per attempt-chain; deliveries with
|
||||
// remaining retries have status=0 and nextAttemptAt in the future.
|
||||
model WebhookDelivery {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
responseId String
|
||||
url String @default("")
|
||||
status Int @default(0)
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
// Scheduled time of the next retry. When null, no more retries are pending.
|
||||
nextAttemptAt DateTime?
|
||||
lastAttemptAt DateTime?
|
||||
// The signed JSON body, kept so a worker can retry without re-loading state.
|
||||
payload String @default("")
|
||||
// Secret used for HMAC, kept with the row for retry independence.
|
||||
secret String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([formId, createdAt])
|
||||
@@index([nextAttemptAt])
|
||||
}
|
||||
|
||||
// Personal access tokens — used for the MCP endpoint.
|
||||
model AccessToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
// sha256 of the secret; secret is shown once.
|
||||
tokenHash String @unique
|
||||
prefix String // first 8 chars of the secret, shown in lists
|
||||
lastUsedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ---------- Tags ----------
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
// Lowercase, unique. Display name preserves the casing of the first use.
|
||||
slug String @unique
|
||||
name String
|
||||
// Optional accent color (hex) for the chip; falls back to theme default.
|
||||
color String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
forms FormTag[]
|
||||
}
|
||||
|
||||
model FormTag {
|
||||
formId String
|
||||
tagId String
|
||||
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([formId, tagId])
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
// ---------- Audit log ----------
|
||||
|
||||
// Append-only feed of mutations. Visible to admins.
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
// Who did the thing. Null only for system actions (cron, webhook worker, etc).
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
// Verb in past tense: "form.created", "form.deleted", "member.role_changed", etc.
|
||||
action String
|
||||
// What the action operated on.
|
||||
entityType String
|
||||
entityId String?
|
||||
// Free-form JSON for action-specific details (old/new values, etc.).
|
||||
metadata String @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([entityType, entityId])
|
||||
@@index([actorId, createdAt])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/lib/auth-handlers";
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}; }
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
@@ -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>.",
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <token></code>.
|
||||
</p>
|
||||
</div>
|
||||
<TokenManager initialTokens={tokens.map((t) => ({ ...t, createdAt: t.createdAt.toISOString(), lastUsedAt: t.lastUsedAt?.toISOString() ?? null }))} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "./auth";
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import clsx, { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) }] };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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("<script>alert('xss')</script>");
|
||||
});
|
||||
|
||||
it("escapes &, <, >, \", '", () => {
|
||||
expect(escapeHtml('a & b < c > d "e" \'f\'')).toBe("a & b < c > d "e" 'f'");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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*"],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: "rgb(var(--bg) / <alpha-value>)",
|
||||
fg: "rgb(var(--fg) / <alpha-value>)",
|
||||
muted: "rgb(var(--muted) / <alpha-value>)",
|
||||
line: "rgb(var(--line) / <alpha-value>)",
|
||||
accent: "rgb(var(--accent) / <alpha-value>)",
|
||||
"accent-fg": "rgb(var(--accent-fg) / <alpha-value>)",
|
||||
danger: "rgb(var(--danger) / <alpha-value>)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
},
|
||||
borderRadius: { DEFAULT: "10px" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["node_modules/**", ".next/**"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
include: ["src/lib/**/*.ts"],
|
||||
exclude: ["src/lib/**/*.test.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user