From 3e5449cf4ff35a88959cd72b6baf7679ea60fac4 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 17 Apr 2026 12:17:11 -0500 Subject: [PATCH] Fix migration test issues: upload button, forgot password, IMAP, FTP UI, surveys nav, integration tests - Mount UploadModal globally in AppShell so upload works from any page - Add /forgot-password, /reset-password, /s, /api/survey/submit to public paths in middleware - Fix IMAP test URL (/api/email/test -> /api/email-watch/test) and body shape - Fix IMAP scan URL to use /api/email-watch with action body - Add FTP server settings section to Upload Sources page with test connection - Add "Surveys" nav item in sidebar with dedicated page showing links, QR codes - Include org slug in form-templates API response for survey URL construction - Add pre-creation "Test Connection" button on new integration page - Create /api/integrations/test endpoint for pre-creation connection testing - Replace overflow-hidden with overflow-clip on Card to fix click/z-index issues Made-with: Cursor --- .../settings/integrations/new/page.tsx | 43 ++- .../settings/upload-sources/page.tsx | 212 ++++++++++++- src/app/(dashboard)/surveys/page.tsx | 295 ++++++++++++++++++ src/app/api/form-templates/route.ts | 1 + src/app/api/integrations/test/route.ts | 38 +++ src/components/layout/app-shell.tsx | 19 ++ src/components/layout/sidebar.tsx | 2 + src/components/ui/card.tsx | 2 +- src/middleware.ts | 4 + 9 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 src/app/(dashboard)/surveys/page.tsx create mode 100644 src/app/api/integrations/test/route.ts diff --git a/src/app/(dashboard)/settings/integrations/new/page.tsx b/src/app/(dashboard)/settings/integrations/new/page.tsx index 72787ea..fc9d1b5 100644 --- a/src/app/(dashboard)/settings/integrations/new/page.tsx +++ b/src/app/(dashboard)/settings/integrations/new/page.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { Loader2, ArrowLeft, Plug, Plus } from "lucide-react"; +import { Loader2, ArrowLeft, Plug, Plus, Check, Zap } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -52,6 +52,7 @@ function NewIntegrationForm() { const [config, setConfig] = React.useState>({}); const [loading, setLoading] = React.useState(true); const [creating, setCreating] = React.useState(false); + const [testStatus, setTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); React.useEffect(() => { fetch("/api/integrations") @@ -74,6 +75,29 @@ function NewIntegrationForm() { const provider = providers.find((p) => p.id === selectedProvider); + const handleTest = async () => { + if (!selectedProvider) return; + setTestStatus("testing"); + try { + const res = await fetch("/api/integrations/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: selectedProvider, config }), + }); + const data = await res.json(); + if (res.ok && data.ok) { + setTestStatus("success"); + toast.success(data.message || "Connection successful"); + } else { + setTestStatus("error"); + toast.error(data.error || "Connection failed"); + } + } catch { + setTestStatus("error"); + toast.error("Connection test failed"); + } + }; + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedProvider) { @@ -234,6 +258,23 @@ function NewIntegrationForm() { > Change Provider + {!provider?.supportsOAuth && ( + + )} + + + + {/* Folder Watch */} diff --git a/src/app/(dashboard)/surveys/page.tsx b/src/app/(dashboard)/surveys/page.tsx new file mode 100644 index 0000000..736616a --- /dev/null +++ b/src/app/(dashboard)/surveys/page.tsx @@ -0,0 +1,295 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { + ClipboardList, + Copy, + Check, + Download, + ExternalLink, + QrCode, + Loader2, + Plus, + Pencil, + ListChecks, +} from "lucide-react"; +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +type SurveyTemplate = { + id: string; + name: string; + slug: string; + description: string | null; + isDefault: boolean; + isActive: boolean; + _count?: { fields: number; cards: number }; + organization: { slug: string }; +}; + +export default function SurveysPage() { + const [templates, setTemplates] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [copiedId, setCopiedId] = React.useState(null); + + React.useEffect(() => { + fetch("/api/form-templates") + .then((r) => r.json()) + .then((data) => setTemplates(data.templates ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; + + const getSurveyUrl = (t: SurveyTemplate) => + `${baseUrl}/s/${t.organization.slug}/${t.slug}`; + + const handleCopy = async (t: SurveyTemplate) => { + const url = getSurveyUrl(t); + try { + await navigator.clipboard.writeText(url); + } catch { + const input = document.createElement("input"); + input.value = url; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + setCopiedId(t.id); + toast.success("Survey link copied"); + setTimeout(() => setCopiedId(null), 2000); + }; + + const handleDownloadQR = async (t: SurveyTemplate) => { + try { + const res = await fetch(`/api/form-templates/${t.id}/qr`); + if (!res.ok) throw new Error(); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `qr-${t.slug}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + toast.error("Failed to download QR code"); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const activeTemplates = templates.filter((t) => t.isActive); + const inactiveTemplates = templates.filter((t) => !t.isActive); + + return ( +
+
+ +
+ + {templates.length === 0 ? ( +
+
+ +
+
+

No form templates yet

+

+ Create a form template to start sharing digital surveys. +

+
+ +
+ ) : ( + <> + {activeTemplates.length > 0 && ( +
+

+ Active Surveys +

+
+ {activeTemplates.map((t) => ( + handleCopy(t)} + onDownloadQR={() => handleDownloadQR(t)} + /> + ))} +
+
+ )} + + {inactiveTemplates.length > 0 && ( +
+

+ Inactive +

+
+ {inactiveTemplates.map((t) => ( + handleCopy(t)} + onDownloadQR={() => handleDownloadQR(t)} + inactive + /> + ))} +
+
+ )} + + )} +
+ ); +} + +function SurveyCard({ + template, + surveyUrl, + copied, + onCopy, + onDownloadQR, + inactive, +}: { + template: SurveyTemplate; + surveyUrl: string; + copied: boolean; + onCopy: () => void; + onDownloadQR: () => void; + inactive?: boolean; +}) { + return ( + + +
+
+ + {template.name} + {template.isDefault && ( + + Default + + )} + {!template.isActive && ( + + Inactive + + )} + + {template.description && ( +

+ {template.description} +

+ )} +
+ +
+
+ +
+ + + {template._count?.fields ?? 0} fields + + + + {template._count?.cards ?? 0} responses + +
+ +
+
+ {surveyUrl} +
+ + +
+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+
+

+ Scan to open the survey on a mobile device. +

+ +
+
+
+
+ ); +} diff --git a/src/app/api/form-templates/route.ts b/src/app/api/form-templates/route.ts index c99fc89..0386287 100644 --- a/src/app/api/form-templates/route.ts +++ b/src/app/api/form-templates/route.ts @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { where: { organizationId: session.user.orgId }, orderBy: { createdAt: "desc" }, include: { + organization: { select: { slug: true } }, _count: { select: { fields: true, cards: true } }, }, }); diff --git a/src/app/api/integrations/test/route.ts b/src/app/api/integrations/test/route.ts new file mode 100644 index 0000000..8971285 --- /dev/null +++ b/src/app/api/integrations/test/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth"; +import { getProvider } from "@/lib/integrations/registry"; + +export async function POST(request: NextRequest) { + try { + await requireApiAuthWithOrg(); + const body = await request.json(); + const { provider: providerId, config } = body as { + provider?: string; + config?: Record; + }; + + if (!providerId) { + return NextResponse.json( + { error: "Provider is required" }, + { status: 400 } + ); + } + + const provider = getProvider(providerId); + if (!provider) { + return NextResponse.json( + { error: "Unknown provider" }, + { status: 400 } + ); + } + + const result = await provider.testConnection(config ?? {}); + return NextResponse.json({ + ok: result.success, + message: result.message, + error: result.success ? undefined : (result.message || "Connection failed"), + }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 211788f..9f213d0 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -1,13 +1,27 @@ "use client"; +import * as React from "react"; import { cn } from "@/lib/utils"; import { TopBar } from "@/components/layout/top-bar"; import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar"; import { EmailVerificationBanner } from "@/components/layout/email-verification-banner"; import { CommandPalette } from "@/components/command-palette"; +import { UploadModal } from "@/components/cards/upload-modal"; function ShellContent({ children }: { children: React.ReactNode }) { const { collapsed } = useSidebar(); + const [uploadOpen, setUploadOpen] = React.useState(false); + const [uploadFiles, setUploadFiles] = React.useState([]); + + React.useEffect(() => { + function handleOpenUpload(e: Event) { + const detail = (e as CustomEvent).detail; + if (detail?.files) setUploadFiles(detail.files); + setUploadOpen(true); + } + window.addEventListener("open-upload-modal", handleOpenUpload); + return () => window.removeEventListener("open-upload-modal", handleOpenUpload); + }, []); return (
@@ -21,6 +35,11 @@ function ShellContent({ children }: { children: React.ReactNode }) { + 0 ? uploadFiles : undefined} + />
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-4 overflow-clip rounded-xl py-4 text-sm text-card-foreground transition-shadow has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", variant === "glass" ? "glass-card" : "bg-card ring-1 ring-foreground/10 glass", diff --git a/src/middleware.ts b/src/middleware.ts index fe9b389..b10bd31 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,12 +10,16 @@ const publicPaths = [ "/login", "/signup", "/invite", + "/forgot-password", + "/reset-password", "/setup", + "/s", "/api/auth", "/api/health", "/api/setup", "/api/onboarding", "/api/invitations/verify", + "/api/survey/submit", "/api/jobs/process", "/api/email-watch/poll", "/api/ftp-watch/poll",