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
This commit is contained in:
Randall Stillwell 2026-04-17 12:17:11 -05:00
parent 70134b4967
commit 3e5449cf4f
9 changed files with 607 additions and 9 deletions

View file

@ -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<Record<string, string>>({});
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
</Button>
{!provider?.supportsOAuth && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testStatus === "testing"}
>
{testStatus === "testing" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : testStatus === "success" ? (
<Check className="mr-2 size-4 text-emerald-500" />
) : (
<Zap className="mr-2 size-4" />
)}
Test Connection
</Button>
)}
<Button type="submit" disabled={creating} className="flex-1">
{creating ? (
<Loader2 className="mr-2 size-4 animate-spin" />

View file

@ -7,6 +7,7 @@ import {
Save,
Mail,
FolderSearch,
Server,
Wifi,
WifiOff,
Check,
@ -32,7 +33,7 @@ import {
SelectValue,
} from "@/components/ui/select";
type EmailSettings = {
type SourceSettings = {
emailImapHost: string;
emailImapPort: number;
emailImapUser: string;
@ -42,12 +43,20 @@ type EmailSettings = {
emailWatching: boolean;
emailProcessed: string;
emailProcessedFolder: string;
ftpEnabled: boolean;
ftpHost: string;
ftpPort: number;
ftpUser: string;
ftpPass: string;
ftpTls: boolean;
ftpIncomingDir: string;
ftpProcessedDir: string;
watchDir: string;
watching: boolean;
};
export default function UploadSourcesPage() {
const [settings, setSettings] = React.useState<EmailSettings>({
const [settings, setSettings] = React.useState<SourceSettings>({
emailImapHost: "",
emailImapPort: 993,
emailImapUser: "",
@ -57,6 +66,14 @@ export default function UploadSourcesPage() {
emailWatching: false,
emailProcessed: "mark_read",
emailProcessedFolder: "Processed",
ftpEnabled: false,
ftpHost: "",
ftpPort: 21,
ftpUser: "",
ftpPass: "",
ftpTls: true,
ftpIncomingDir: "/incoming",
ftpProcessedDir: "/processed",
watchDir: "",
watching: false,
});
@ -65,6 +82,9 @@ export default function UploadSourcesPage() {
const [emailTestStatus, setEmailTestStatus] = React.useState<
"idle" | "testing" | "success" | "error"
>("idle");
const [ftpTestStatus, setFtpTestStatus] = React.useState<
"idle" | "testing" | "success" | "error"
>("idle");
const [scanning, setScanning] = React.useState(false);
React.useEffect(() => {
@ -81,6 +101,14 @@ export default function UploadSourcesPage() {
emailWatching: data.emailWatching || false,
emailProcessed: data.emailProcessed || "mark_read",
emailProcessedFolder: data.emailProcessedFolder || "Processed",
ftpEnabled: data.ftpEnabled || false,
ftpHost: data.ftpHost || "",
ftpPort: data.ftpPort || 21,
ftpUser: data.ftpUser || "",
ftpPass: data.ftpPass || "",
ftpTls: data.ftpTls ?? true,
ftpIncomingDir: data.ftpIncomingDir || "/incoming",
ftpProcessedDir: data.ftpProcessedDir || "/processed",
watchDir: data.watchDir || "",
watching: data.watching || false,
});
@ -109,14 +137,21 @@ export default function UploadSourcesPage() {
const testEmail = async () => {
setEmailTestStatus("testing");
try {
const res = await fetch("/api/email/test", {
const res = await fetch("/api/email-watch/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
body: JSON.stringify({
host: settings.emailImapHost,
port: settings.emailImapPort,
user: settings.emailImapUser,
pass: settings.emailImapPass,
tls: settings.emailImapTls,
}),
});
const data = await res.json();
setEmailTestStatus(res.ok ? "success" : "error");
if (res.ok) toast.success("Email connection successful");
else toast.error("Email connection failed");
if (res.ok) toast.success(data.message || "Email connection successful");
else toast.error(data.error || "Email connection failed");
} catch {
setEmailTestStatus("error");
toast.error("Email test failed");
@ -126,10 +161,17 @@ export default function UploadSourcesPage() {
const scanNow = async () => {
setScanning(true);
try {
const res = await fetch("/api/email/scan", { method: "POST" });
const res = await fetch("/api/email-watch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "scan" }),
});
if (res.ok) {
const data = await res.json();
toast.success(`Scanned ${data.processed || 0} emails`);
} else {
const data = await res.json().catch(() => ({}));
toast.error(data.error || "Scan failed");
}
} catch {
toast.error("Scan failed");
@ -138,6 +180,34 @@ export default function UploadSourcesPage() {
}
};
const testFtp = async () => {
setFtpTestStatus("testing");
try {
const res = await fetch("/api/ftp-watch/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: settings.ftpHost,
port: settings.ftpPort,
user: settings.ftpUser,
pass: settings.ftpPass,
tls: settings.ftpTls,
incomingDir: settings.ftpIncomingDir,
}),
});
const data = await res.json();
setFtpTestStatus(res.ok && data.ok ? "success" : "error");
if (res.ok && data.ok) {
toast.success(`FTP connected — ${data.files ?? 0} file(s) found`);
} else {
toast.error(data.error || "FTP connection failed");
}
} catch {
setFtpTestStatus("error");
toast.error("FTP test failed");
}
};
const toggleFolderWatch = async () => {
const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
try {
@ -294,6 +364,134 @@ export default function UploadSourcesPage() {
</CardContent>
</Card>
{/* FTP */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server className="size-4" />
FTP Server
</CardTitle>
<CardDescription>
Poll an FTP server for new scanned files. Files are downloaded,
processed, then moved to a &ldquo;processed&rdquo; directory.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.ftpEnabled}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, ftpEnabled: !!v }))
}
/>
<Label className="text-sm">Enable FTP watcher</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>FTP Host</Label>
<Input
value={settings.ftpHost}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpHost: e.target.value }))
}
placeholder="ftp.example.com"
/>
</div>
<div className="space-y-2">
<Label>Port</Label>
<Input
type="number"
value={settings.ftpPort}
onChange={(e) =>
setSettings((s) => ({
...s,
ftpPort: parseInt(e.target.value) || 21,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Username</Label>
<Input
value={settings.ftpUser}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpUser: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input
type="password"
value={settings.ftpPass}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpPass: e.target.value }))
}
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={settings.ftpTls}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, ftpTls: !!v }))
}
/>
<Label className="text-sm">Use TLS</Label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Incoming Directory</Label>
<Input
value={settings.ftpIncomingDir}
onChange={(e) =>
setSettings((s) => ({ ...s, ftpIncomingDir: e.target.value }))
}
placeholder="/incoming"
/>
</div>
<div className="space-y-2">
<Label>Processed Directory</Label>
<Input
value={settings.ftpProcessedDir}
onChange={(e) =>
setSettings((s) => ({
...s,
ftpProcessedDir: e.target.value,
}))
}
placeholder="/processed"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={testFtp}
disabled={!settings.ftpHost || !settings.ftpUser}
>
{ftpTestStatus === "testing" ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : ftpTestStatus === "success" ? (
<Check className="mr-1.5 size-3.5 text-emerald-500" />
) : (
<Server className="mr-1.5 size-3.5" />
)}
Test Connection
</Button>
</div>
</CardContent>
</Card>
{/* Folder Watch */}
<Card className="glass-card">
<CardHeader>

View file

@ -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<SurveyTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [copiedId, setCopiedId] = React.useState<string | null>(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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
const activeTemplates = templates.filter((t) => t.isActive);
const inactiveTemplates = templates.filter((t) => !t.isActive);
return (
<div className="space-y-6">
<Header
title="Surveys"
description="Share digital surveys via link or QR code"
icon={ClipboardList}
>
<Button
size="sm"
render={<Link href="/settings/forms" />}
>
<Pencil className="size-4" />
Manage Templates
</Button>
</Header>
{templates.length === 0 ? (
<div className="glass-card flex flex-col items-center gap-3 rounded-2xl p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ClipboardList className="size-6" />
</div>
<div>
<p className="font-medium text-foreground">No form templates yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Create a form template to start sharing digital surveys.
</p>
</div>
<Button
size="sm"
className="mt-2"
render={<Link href="/settings/forms" />}
>
<Plus className="size-4" />
Create Template
</Button>
</div>
) : (
<>
{activeTemplates.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
Active Surveys
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{activeTemplates.map((t) => (
<SurveyCard
key={t.id}
template={t}
surveyUrl={getSurveyUrl(t)}
copied={copiedId === t.id}
onCopy={() => handleCopy(t)}
onDownloadQR={() => handleDownloadQR(t)}
/>
))}
</div>
</div>
)}
{inactiveTemplates.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
Inactive
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{inactiveTemplates.map((t) => (
<SurveyCard
key={t.id}
template={t}
surveyUrl={getSurveyUrl(t)}
copied={copiedId === t.id}
onCopy={() => handleCopy(t)}
onDownloadQR={() => handleDownloadQR(t)}
inactive
/>
))}
</div>
</div>
)}
</>
)}
</div>
);
}
function SurveyCard({
template,
surveyUrl,
copied,
onCopy,
onDownloadQR,
inactive,
}: {
template: SurveyTemplate;
surveyUrl: string;
copied: boolean;
onCopy: () => void;
onDownloadQR: () => void;
inactive?: boolean;
}) {
return (
<Card className={`glass-card ${inactive ? "opacity-60" : ""}`}>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base">
<span className="truncate">{template.name}</span>
{template.isDefault && (
<Badge variant="secondary" className="shrink-0">
Default
</Badge>
)}
{!template.isActive && (
<Badge variant="outline" className="shrink-0 text-muted-foreground">
Inactive
</Badge>
)}
</CardTitle>
{template.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-1">
{template.description}
</p>
)}
</div>
<Button
variant="outline"
size="xs"
render={<Link href={`/settings/forms/${template.id}`} />}
>
<Pencil className="size-3" />
Edit
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<ListChecks className="size-3.5" />
{template._count?.fields ?? 0} fields
</span>
<span className="inline-flex items-center gap-1">
<ClipboardList className="size-3.5" />
{template._count?.cards ?? 0} responses
</span>
</div>
<div className="flex items-center gap-2">
<div className="glass-input min-w-0 flex-1 truncate rounded-lg px-3 py-1.5 text-xs text-muted-foreground">
{surveyUrl}
</div>
<Button variant="outline" size="xs" onClick={onCopy}>
{copied ? (
<Check className="size-3 text-emerald-500" />
) : (
<Copy className="size-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
<Button
variant="outline"
size="xs"
render={
<a href={surveyUrl} target="_blank" rel="noopener noreferrer" />
}
>
<ExternalLink className="size-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="overflow-hidden rounded-lg border border-border bg-white p-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/form-templates/${template.id}/qr`}
alt="QR Code"
width={80}
height={80}
className="size-20"
/>
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">
Scan to open the survey on a mobile device.
</p>
<Button variant="outline" size="xs" onClick={onDownloadQR}>
<Download className="size-3" />
Download QR
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -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 } },
},
});

View file

@ -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<string, unknown>;
};
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);
}
}

View file

@ -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<File[]>([]);
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 (
<div className="relative min-h-screen">
@ -21,6 +35,11 @@ function ShellContent({ children }: { children: React.ReactNode }) {
<TopBar />
<Sidebar />
<CommandPalette />
<UploadModal
open={uploadOpen}
onOpenChange={setUploadOpen}
initialFiles={uploadFiles.length > 0 ? uploadFiles : undefined}
/>
<main
id="main-content"
className={cn(

View file

@ -9,6 +9,7 @@ import {
CalendarDays,
Users,
BarChart3,
ClipboardList,
Settings,
ChevronsLeft,
ChevronsRight,
@ -29,6 +30,7 @@ const navItems = [
{ href: "/cards", label: "Response Cards", icon: CreditCard },
{ href: "/events", label: "Collection Days", icon: CalendarDays },
{ href: "/people", label: "People", icon: Users },
{ href: "/surveys", label: "Surveys", icon: ClipboardList },
{ href: "/reports", label: "Reports", icon: BarChart3 },
];

View file

@ -16,7 +16,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden 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",
"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",

View file

@ -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",