Add onboarding wizard, email verification, integration architecture, and settings restructure
- Auto-sign-in after registration instead of redirect to login - Email verification system with token generation, send/confirm API routes, and persistent banner - 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete) - Middleware redirects owners with incomplete onboarding to /onboarding - Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export) - Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes - Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback - Migration script for existing Monday.com/webhook config to Integration rows - Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation - Integration hub UI with provider tiles, connect flow, and individual config pages - Post-onboarding contextual guidance cards on dashboard with dismissible hints - Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember Made-with: Cursor
This commit is contained in:
parent
d3e7374439
commit
a975043670
48 changed files with 6087 additions and 1406 deletions
|
|
@ -30,5 +30,13 @@ AUTHENTIK_CLIENT_SECRET=""
|
||||||
AUTHENTIK_URL="https://auth.stillwell.cloud"
|
AUTHENTIK_URL="https://auth.stillwell.cloud"
|
||||||
AUTHENTIK_API_TOKEN=""
|
AUTHENTIK_API_TOKEN=""
|
||||||
|
|
||||||
|
# SMTP for outbound email (verification, invitations)
|
||||||
|
# Falls back to EMAIL_IMAP_* values if not set
|
||||||
|
SMTP_HOST=""
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER=""
|
||||||
|
SMTP_PASS=""
|
||||||
|
SMTP_FROM=""
|
||||||
|
|
||||||
# Environment indicator (set to "staging" for staging deployments)
|
# Environment indicator (set to "staging" for staging deployments)
|
||||||
NEXT_PUBLIC_ENV=""
|
NEXT_PUBLIC_ENV=""
|
||||||
|
|
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -32,6 +32,7 @@
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf2pic": "^3.2.0",
|
"pdf2pic": "^3.2.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|
@ -51,6 +52,7 @@
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^25.4.0",
|
"@types/node": "^25.4.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
@ -4946,6 +4948,16 @@
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz",
|
||||||
|
|
@ -10658,6 +10670,15 @@
|
||||||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/npm-run-path": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf2pic": "^3.2.0",
|
"pdf2pic": "^3.2.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^25.4.0",
|
"@types/node": "^25.4.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
105
prisma/migrate-integrations.ts
Normal file
105
prisma/migrate-integrations.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* One-time migration: reads Monday.com and webhook config from AppSettings
|
||||||
|
* and creates corresponding Integration rows for each organization.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx prisma/migrate-integrations.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const settings = await prisma.appSettings.findUnique({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
console.log("No AppSettings found — nothing to migrate.");
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgs = await prisma.organization.findMany();
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
console.log("No organizations found — nothing to migrate.");
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = orgs[0].id;
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
if (settings.mondayEnabled && settings.mondayApiToken && settings.mondayBoardId) {
|
||||||
|
const existing = await prisma.integration.findFirst({
|
||||||
|
where: { organizationId: orgId, provider: "monday" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.integration.create({
|
||||||
|
data: {
|
||||||
|
organizationId: orgId,
|
||||||
|
provider: "monday",
|
||||||
|
name: "Monday.com (migrated)",
|
||||||
|
enabled: settings.mondayEnabled,
|
||||||
|
config: {
|
||||||
|
apiToken: settings.mondayApiToken,
|
||||||
|
boardId: settings.mondayBoardId,
|
||||||
|
columnMap: settings.mondayColumnMap || {},
|
||||||
|
},
|
||||||
|
triggerEvents: ["card_reviewed", "card_exported"],
|
||||||
|
syncDirection: "push",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
console.log("Created Monday.com integration from AppSettings.");
|
||||||
|
} else {
|
||||||
|
console.log("Monday.com integration already exists — skipping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.webhookEnabled && settings.webhookUrl) {
|
||||||
|
const existing = await prisma.integration.findFirst({
|
||||||
|
where: { organizationId: orgId, provider: "webhook" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.integration.create({
|
||||||
|
data: {
|
||||||
|
organizationId: orgId,
|
||||||
|
provider: "webhook",
|
||||||
|
name: "Webhook (migrated)",
|
||||||
|
enabled: settings.webhookEnabled,
|
||||||
|
config: {
|
||||||
|
url: settings.webhookUrl,
|
||||||
|
secret: settings.webhookSecret,
|
||||||
|
},
|
||||||
|
triggerEvents: (settings.webhookEvents as string[]) || [
|
||||||
|
"ocr_complete",
|
||||||
|
"card_reviewed",
|
||||||
|
],
|
||||||
|
syncDirection: "push",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
console.log("Created Webhook integration from AppSettings.");
|
||||||
|
} else {
|
||||||
|
console.log("Webhook integration already exists — skipping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete: ${created} integration(s) created.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Migration failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -77,6 +77,8 @@ model Organization {
|
||||||
type String @default("church")
|
type String @default("church")
|
||||||
timezone String @default("America/Chicago")
|
timezone String @default("America/Chicago")
|
||||||
settings Json?
|
settings Json?
|
||||||
|
onboardingComplete Boolean @default(false)
|
||||||
|
onboardingStep Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ model Organization {
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
cards ResponseCard[]
|
cards ResponseCard[]
|
||||||
jobs ProcessingJob[]
|
jobs ProcessingJob[]
|
||||||
|
integrations Integration[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrgMember {
|
model OrgMember {
|
||||||
|
|
@ -93,6 +96,7 @@ model OrgMember {
|
||||||
userId String
|
userId String
|
||||||
organizationId String
|
organizationId String
|
||||||
role String @default("viewer")
|
role String @default("viewer")
|
||||||
|
dismissedHints Json?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
@ -131,6 +135,28 @@ model CollectionDay {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Integrations ────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Integration {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
provider String
|
||||||
|
name String
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
config Json
|
||||||
|
fieldMapping Json?
|
||||||
|
syncDirection String @default("push")
|
||||||
|
triggerEvents Json?
|
||||||
|
lastSyncAt DateTime?
|
||||||
|
lastSyncStatus String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organizationId, provider, name])
|
||||||
|
@@index([organizationId, provider])
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Invitations ─────────────────────────────────────────────
|
// ─── Invitations ─────────────────────────────────────────────
|
||||||
|
|
||||||
model Invitation {
|
model Invitation {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ScanLine, Mail, Lock, User, Loader2 } from "lucide-react";
|
import { ScanLine, Mail, Lock, User, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -17,7 +18,6 @@ export default function SignupPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignupForm() {
|
function SignupForm() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get("token") || "";
|
const token = searchParams.get("token") || "";
|
||||||
|
|
||||||
|
|
@ -69,7 +69,11 @@ function SignupForm() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/login?registered=true");
|
await signIn("credentials", {
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
callbackUrl: "/",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong. Please try again.");
|
setError("Something went wrong. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
995
src/app/(dashboard)/onboarding/page.tsx
Normal file
995
src/app/(dashboard)/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,995 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
MapPin,
|
||||||
|
CalendarDays,
|
||||||
|
Upload,
|
||||||
|
Cpu,
|
||||||
|
Plug,
|
||||||
|
PartyPopper,
|
||||||
|
Loader2,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Mail,
|
||||||
|
FolderOpen,
|
||||||
|
Key,
|
||||||
|
SkipForward,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: "Organization", icon: Building2 },
|
||||||
|
{ label: "Location", icon: MapPin },
|
||||||
|
{ label: "Services", icon: CalendarDays },
|
||||||
|
{ label: "Upload Source", icon: Upload },
|
||||||
|
{ label: "AI Provider", icon: Cpu },
|
||||||
|
{ label: "Integrations", icon: Plug },
|
||||||
|
{ label: "Complete", icon: PartyPopper },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORG_TYPES = [
|
||||||
|
{ value: "church", label: "Church" },
|
||||||
|
{ value: "ministry", label: "Ministry" },
|
||||||
|
{ value: "nonprofit", label: "Nonprofit" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIMEZONES = [
|
||||||
|
{ group: "US", zones: ["America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Anchorage", "Pacific/Honolulu"] },
|
||||||
|
{ group: "Canada", zones: ["America/Toronto", "America/Vancouver", "America/Edmonton", "America/Halifax"] },
|
||||||
|
{ group: "Europe", zones: ["Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid"] },
|
||||||
|
{ group: "Asia/Pacific", zones: ["Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Australia/Sydney"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [org, setOrg] = useState({
|
||||||
|
name: "",
|
||||||
|
type: "church",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [locations, setLocations] = useState([{ name: "", address: "" }]);
|
||||||
|
|
||||||
|
const [services, setServices] = useState([
|
||||||
|
{ name: "Sunday Morning", dayOfWeek: 0, timeStart: "09:00", timeEnd: "10:30" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [uploadSource, setUploadSource] = useState("manual");
|
||||||
|
const [emailConfig, setEmailConfig] = useState({
|
||||||
|
host: "",
|
||||||
|
port: 993,
|
||||||
|
user: "",
|
||||||
|
pass: "",
|
||||||
|
tls: true,
|
||||||
|
});
|
||||||
|
const [watchDir, setWatchDir] = useState("");
|
||||||
|
const [generatedApiKey, setGeneratedApiKey] = useState("");
|
||||||
|
|
||||||
|
const [aiProvider, setAiProvider] = useState("gateway");
|
||||||
|
const [aiModel, setAiModel] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/onboarding")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.onboardingComplete) {
|
||||||
|
router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep(data.currentStep || 0);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const submitStep = useCallback(
|
||||||
|
async (step: number, data: Record<string, unknown>) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/onboarding", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ step, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(result.error || "Something went wrong");
|
||||||
|
setSubmitting(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.complete) {
|
||||||
|
router.replace("/");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(result.nextStep);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleOrgSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitStep(1, org);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocationsSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const valid = locations.filter((l) => l.name.trim());
|
||||||
|
if (valid.length === 0) {
|
||||||
|
setError("Add at least one location");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitStep(2, { locations: valid });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServicesSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitStep(3, { services: services.filter((s) => s.name.trim()) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = await submitStep(4, {
|
||||||
|
uploadSource,
|
||||||
|
emailConfig: uploadSource === "email" ? emailConfig : undefined,
|
||||||
|
watchDir: uploadSource === "folder" ? watchDir : undefined,
|
||||||
|
});
|
||||||
|
if (result?.apiKey) {
|
||||||
|
setGeneratedApiKey(result.apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAiSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitStep(5, { aiProvider, aiModel });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIntegrationsSubmit() {
|
||||||
|
submitStep(6, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComplete() {
|
||||||
|
submitStep(7, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl py-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Set up your workspace
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Get Echo OCR ready in just a few steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="mb-8 flex items-center justify-center gap-1 sm:gap-2">
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const done = currentStep > i;
|
||||||
|
const active = currentStep === i;
|
||||||
|
const StepIcon = step.icon;
|
||||||
|
return (
|
||||||
|
<div key={step.label} className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex size-8 items-center justify-center rounded-full transition-colors ${
|
||||||
|
done
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: active
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<Check className="size-4" />
|
||||||
|
) : (
|
||||||
|
<StepIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`hidden text-xs font-medium lg:inline ${
|
||||||
|
active ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<ArrowRight className="size-3 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="glass-card rounded-2xl p-6 sm:p-8">
|
||||||
|
{/* Step 1: Organization */}
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<form onSubmit={handleOrgSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Your Organization</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Tell us about your church or organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="orgName">Organization Name</Label>
|
||||||
|
<Input
|
||||||
|
id="orgName"
|
||||||
|
value={org.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrg((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
placeholder="Grace Community Church"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="orgType">Type</Label>
|
||||||
|
<select
|
||||||
|
id="orgType"
|
||||||
|
value={org.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrg((p) => ({ ...p, type: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{ORG_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timezone">Timezone</Label>
|
||||||
|
<select
|
||||||
|
id="timezone"
|
||||||
|
value={org.timezone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrg((p) => ({ ...p, timezone: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{TIMEZONES.map((group) => (
|
||||||
|
<optgroup key={group.group} label={group.group}>
|
||||||
|
{group.zones.map((tz) => (
|
||||||
|
<option key={tz} value={tz}>
|
||||||
|
{tz.replace(/_/g, " ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Locations */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<form onSubmit={handleLocationsSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Locations</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Add your campuses or physical locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locations.map((loc, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="space-y-3 rounded-lg border border-border/50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Location {i + 1}
|
||||||
|
</span>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setLocations((prev) =>
|
||||||
|
prev.filter((_, idx) => idx !== i)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={loc.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocations((prev) =>
|
||||||
|
prev.map((l, idx) =>
|
||||||
|
idx === i ? { ...l, name: e.target.value } : l
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
placeholder="Main Campus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Address (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={loc.address}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocations((prev) =>
|
||||||
|
prev.map((l, idx) =>
|
||||||
|
idx === i ? { ...l, address: e.target.value } : l
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="123 Church St, City, ST 12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setLocations((prev) => [...prev, { name: "", address: "" }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
Add another location
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setCurrentStep(0)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Service Schedule */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<form onSubmit={handleServicesSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Service Schedule</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
When do you typically collect response cards?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{services.map((svc, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="space-y-3 rounded-lg border border-border/50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Service {i + 1}
|
||||||
|
</span>
|
||||||
|
{services.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.filter((_, idx) => idx !== i)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Service Name</Label>
|
||||||
|
<Input
|
||||||
|
value={svc.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, name: e.target.value } : s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Sunday Morning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Day of Week</Label>
|
||||||
|
<select
|
||||||
|
value={svc.dayOfWeek}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.map((s, idx) =>
|
||||||
|
idx === i
|
||||||
|
? { ...s, dayOfWeek: parseInt(e.target.value) }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{DAYS_OF_WEEK.map((day, idx) => (
|
||||||
|
<option key={day} value={idx}>
|
||||||
|
{day}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Start Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={svc.timeStart}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.map((s, idx) =>
|
||||||
|
idx === i
|
||||||
|
? { ...s, timeStart: e.target.value }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={svc.timeEnd}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, timeEnd: e.target.value } : s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setServices((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
dayOfWeek: 0,
|
||||||
|
timeStart: "09:00",
|
||||||
|
timeEnd: "10:30",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
Add another service
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setCurrentStep(1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Upload Source */}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<form onSubmit={handleUploadSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Upload Source</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
How will response cards get into Echo OCR?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
id: "manual",
|
||||||
|
icon: Upload,
|
||||||
|
label: "Manual Upload",
|
||||||
|
desc: "Upload scans directly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
icon: Mail,
|
||||||
|
label: "Email Inbox",
|
||||||
|
desc: "Auto-import from email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "folder",
|
||||||
|
icon: FolderOpen,
|
||||||
|
label: "Folder Watch",
|
||||||
|
desc: "Watch a local folder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "api",
|
||||||
|
icon: Key,
|
||||||
|
label: "API",
|
||||||
|
desc: "Push via REST API",
|
||||||
|
},
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadSource(opt.id)}
|
||||||
|
className={`flex flex-col items-center gap-2 rounded-xl border p-4 text-center transition-colors ${
|
||||||
|
uploadSource === opt.id
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border/50 hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<opt.icon
|
||||||
|
className={`size-6 ${uploadSource === opt.id ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{opt.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{opt.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadSource === "email" && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 p-4">
|
||||||
|
<h3 className="text-sm font-medium">IMAP Configuration</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Host</Label>
|
||||||
|
<Input
|
||||||
|
value={emailConfig.host}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailConfig((p) => ({
|
||||||
|
...p,
|
||||||
|
host: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="imap.gmail.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Port</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={emailConfig.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailConfig((p) => ({
|
||||||
|
...p,
|
||||||
|
port: parseInt(e.target.value) || 993,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Username</Label>
|
||||||
|
<Input
|
||||||
|
value={emailConfig.user}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailConfig((p) => ({ ...p, user: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="echo-ocr@church.org"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={emailConfig.pass}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailConfig((p) => ({ ...p, pass: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadSource === "folder" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Watch Directory Path</Label>
|
||||||
|
<Input
|
||||||
|
value={watchDir}
|
||||||
|
onChange={(e) => setWatchDir(e.target.value)}
|
||||||
|
placeholder="/mnt/scans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generatedApiKey && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||||
|
<p className="text-sm font-medium text-emerald-400">
|
||||||
|
Your API Key (save this — it won't be shown again):
|
||||||
|
</p>
|
||||||
|
<code className="block break-all rounded bg-background p-2 text-xs">
|
||||||
|
{generatedApiKey}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setCurrentStep(2)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: AI Provider */}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<form onSubmit={handleAiSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">AI Provider</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Choose how Echo OCR processes your scanned cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
id: "gateway",
|
||||||
|
label: "AI Gateway (Recommended)",
|
||||||
|
desc: "Routes to OpenAI, Google, or Anthropic via Vercel AI Gateway. Best accuracy.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ollama",
|
||||||
|
label: "Ollama (Self-hosted)",
|
||||||
|
desc: "Run models locally. Requires a GPU server on your network.",
|
||||||
|
},
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAiProvider(opt.id)}
|
||||||
|
className={`flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-colors ${
|
||||||
|
aiProvider === opt.id
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border/50 hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cpu
|
||||||
|
className={`mt-0.5 size-5 shrink-0 ${aiProvider === opt.id ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{opt.label}</span>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{opt.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="aiModel">
|
||||||
|
Model {aiProvider === "gateway" ? "(optional)" : ""}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="aiModel"
|
||||||
|
value={aiModel}
|
||||||
|
onChange={(e) => setAiModel(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
aiProvider === "gateway"
|
||||||
|
? "gpt-4o-mini (auto-selected if blank)"
|
||||||
|
: "llava:7b"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setCurrentStep(3)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Integrations (skippable) */}
|
||||||
|
{currentStep === 5 && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Integrations</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Where should processed cards go? You can configure these later
|
||||||
|
in Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
id: "planning_center",
|
||||||
|
name: "Planning Center",
|
||||||
|
desc: "Sync people to PCO",
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monday",
|
||||||
|
name: "Monday.com",
|
||||||
|
desc: "Push to Monday boards",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "airtable",
|
||||||
|
name: "Airtable",
|
||||||
|
desc: "Add rows to Airtable bases",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "google_sheets",
|
||||||
|
name: "Google Sheets",
|
||||||
|
desc: "Append to spreadsheets",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "webhook",
|
||||||
|
name: "Webhook",
|
||||||
|
desc: "Send to any URL",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "csv_export",
|
||||||
|
name: "CSV / Excel",
|
||||||
|
desc: "Auto-generate exports",
|
||||||
|
featured: false,
|
||||||
|
},
|
||||||
|
].map((int) => (
|
||||||
|
<div
|
||||||
|
key={int.id}
|
||||||
|
className={`relative flex flex-col items-center gap-2 rounded-xl border p-4 text-center ${
|
||||||
|
int.featured
|
||||||
|
? "border-primary/50 bg-primary/5"
|
||||||
|
: "border-border/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{int.featured && (
|
||||||
|
<span className="absolute -top-2.5 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-primary-foreground">
|
||||||
|
RECOMMENDED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Plug className="size-6 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{int.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{int.desc}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-[11px] text-muted-foreground/60">
|
||||||
|
Configure in Settings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setCurrentStep(4)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleIntegrationsSubmit}
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<SkipForward className="mr-2 size-4" />
|
||||||
|
Continue — set up later
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 7: Complete */}
|
||||||
|
{currentStep === 6 && (
|
||||||
|
<div className="space-y-6 text-center">
|
||||||
|
<div className="mx-auto flex size-20 items-center justify-center rounded-full bg-emerald-500/20">
|
||||||
|
<PartyPopper className="size-10 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">You're all set!</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Your workspace is ready. Here's what you can do next:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 text-left sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/cards")}
|
||||||
|
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<Upload className="size-5 text-primary" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Upload your first card
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Scan and process a response card
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/settings/users")}
|
||||||
|
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<Building2 className="size-5 text-primary" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Invite team members
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Add reviewers and editors
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex flex-col gap-1.5 rounded-xl border border-border/50 p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<PartyPopper className="size-5 text-primary" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Explore the dashboard
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
See your workspace overview
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleComplete}
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
|
@ -11,28 +11,55 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Users,
|
Users,
|
||||||
|
Plug,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number;
|
total: number;
|
||||||
byOcrStatus: { ocrStatus: string; _count: { id: number } }[];
|
byOcrStatus: Record<string, number>;
|
||||||
byReviewStatus: { reviewStatus: string; _count: { id: number } }[];
|
byReviewStatus: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatCount(
|
const HINTS_STORAGE_KEY = "echo-ocr-dismissed-hints";
|
||||||
groups: { ocrStatus?: string; reviewStatus?: string; _count: { id: number } }[],
|
|
||||||
key: string,
|
function loadDismissedHints(): string[] {
|
||||||
value: string
|
if (typeof window === "undefined") return [];
|
||||||
): number {
|
try {
|
||||||
const match = groups.find((g) => (g as Record<string, unknown>)[key] === value);
|
const raw = localStorage.getItem(HINTS_STORAGE_KEY);
|
||||||
return match?._count.id ?? 0;
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissHint(id: string) {
|
||||||
|
const current = loadDismissedHints();
|
||||||
|
if (!current.includes(id)) {
|
||||||
|
localStorage.setItem(
|
||||||
|
HINTS_STORAGE_KEY,
|
||||||
|
JSON.stringify([...current, id])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardHomePage() {
|
export default function DashboardHomePage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dismissedHints, setDismissedHints] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDismissedHints(loadDismissedHints());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback((id: string) => {
|
||||||
|
dismissHint(id);
|
||||||
|
setDismissedHints((prev) => [...prev, id]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/stats")
|
fetch("/api/stats")
|
||||||
|
|
@ -42,10 +69,10 @@ export default function DashboardHomePage() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const completed = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "complete") : 0;
|
const completed = stats?.byOcrStatus?.complete ?? 0;
|
||||||
const errors = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "error") : 0;
|
const errors = stats?.byOcrStatus?.error ?? 0;
|
||||||
const pending = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "pending") + getStatCount(stats.byOcrStatus, "ocrStatus", "processing") : 0;
|
const pending = (stats?.byOcrStatus?.pending ?? 0) + (stats?.byOcrStatus?.processing ?? 0);
|
||||||
const unreviewed = stats ? getStatCount(stats.byReviewStatus, "reviewStatus", "unreviewed") : 0;
|
const unreviewed = stats?.byReviewStatus?.unreviewed ?? 0;
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
{
|
{
|
||||||
|
|
@ -174,6 +201,112 @@ export default function DashboardHomePage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contextual guidance cards */}
|
||||||
|
{!loading && (
|
||||||
|
<GuidanceCards
|
||||||
|
totalCards={stats?.total ?? 0}
|
||||||
|
dismissedHints={dismissedHints}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuidanceCards({
|
||||||
|
totalCards,
|
||||||
|
dismissedHints,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
totalCards: number;
|
||||||
|
dismissedHints: string[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const hints = [
|
||||||
|
{
|
||||||
|
id: "no_cards",
|
||||||
|
show: totalCards === 0,
|
||||||
|
icon: Upload,
|
||||||
|
title: "Upload your first response card",
|
||||||
|
description:
|
||||||
|
"Get started by scanning or importing a response card. You can upload PDFs, images, or use the email inbox.",
|
||||||
|
href: "/cards",
|
||||||
|
cta: "Go to Cards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "no_integrations",
|
||||||
|
show: true,
|
||||||
|
icon: Plug,
|
||||||
|
title: "Connect an integration",
|
||||||
|
description:
|
||||||
|
"Sync processed cards to Planning Center, Monday.com, Airtable, or any webhook endpoint.",
|
||||||
|
href: "/settings/integrations",
|
||||||
|
cta: "Set up Integrations",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invite_team",
|
||||||
|
show: true,
|
||||||
|
icon: UserPlus,
|
||||||
|
title: "Invite team members",
|
||||||
|
description:
|
||||||
|
"Add reviewers, editors, or admins to help process and manage response cards.",
|
||||||
|
href: "/settings/users",
|
||||||
|
cta: "Invite Members",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collection_days",
|
||||||
|
show: true,
|
||||||
|
icon: CalendarDays,
|
||||||
|
title: "Set up your service schedule",
|
||||||
|
description:
|
||||||
|
"Configure your weekly collection days to automatically assign cards to events.",
|
||||||
|
href: "/settings/locations",
|
||||||
|
cta: "Manage Schedule",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visible = hints.filter(
|
||||||
|
(h) => h.show && !dismissedHints.includes(h.id)
|
||||||
|
);
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">Getting Started</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{visible.map((hint) => (
|
||||||
|
<div
|
||||||
|
key={hint.id}
|
||||||
|
className="glass-card relative flex items-start gap-4 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<hint.icon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">{hint.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{hint.description}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={hint.href}
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{hint.cta}
|
||||||
|
<ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-2 size-6 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onDismiss(hint.id)}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
196
src/app/(dashboard)/settings/ai/page.tsx
Normal file
196
src/app/(dashboard)/settings/ai/page.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Save, Brain, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const AI_PROVIDERS = [
|
||||||
|
{
|
||||||
|
value: "gateway",
|
||||||
|
label: "Vercel AI Gateway",
|
||||||
|
defaultModel: "openai/gpt-4o-mini",
|
||||||
|
hint: "openai/gpt-4o-mini, google/gemini-2.5-flash, anthropic/claude-sonnet-4-20250514",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ollama",
|
||||||
|
label: "Ollama (Local)",
|
||||||
|
defaultModel: "llava:7b",
|
||||||
|
hint: "llava:7b, moondream, llama3.2-vision",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function AiSettingsPage() {
|
||||||
|
const [aiProvider, setAiProvider] = React.useState("gateway");
|
||||||
|
const [aiModel, setAiModel] = React.useState("");
|
||||||
|
const [ollamaUrl, setOllamaUrl] = React.useState("");
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [testStatus, setTestStatus] = React.useState<
|
||||||
|
"idle" | "testing" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setAiProvider(data.aiProvider || "gateway");
|
||||||
|
setAiModel(data.aiModel || "");
|
||||||
|
setOllamaUrl(data.ollamaUrl || "");
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ aiProvider, aiModel, ollamaUrl }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("AI settings saved");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
setTestStatus("testing");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai-test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider: aiProvider, model: aiModel, ollamaUrl }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setTestStatus("success");
|
||||||
|
toast.success("AI provider connected");
|
||||||
|
} else {
|
||||||
|
setTestStatus("error");
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
toast.error(data.error || "Test failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTestStatus("error");
|
||||||
|
toast.error("Cannot reach AI provider");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProviderChange = (value: string) => {
|
||||||
|
setAiProvider(value);
|
||||||
|
const provider = AI_PROVIDERS.find((p) => p.value === value);
|
||||||
|
if (provider) setAiModel(provider.defaultModel);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentProvider = AI_PROVIDERS.find((p) => p.value === aiProvider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Brain className="size-4" />
|
||||||
|
AI Provider Configuration
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose the AI service that processes your scanned response cards.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Provider</Label>
|
||||||
|
<Select value={aiProvider} onValueChange={(v) => v && handleProviderChange(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AI_PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Model</Label>
|
||||||
|
<Input
|
||||||
|
value={aiModel}
|
||||||
|
onChange={(e) => setAiModel(e.target.value)}
|
||||||
|
placeholder={currentProvider?.defaultModel}
|
||||||
|
/>
|
||||||
|
{currentProvider && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Options: {currentProvider.hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiProvider === "ollama" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ollama URL</Label>
|
||||||
|
<Input
|
||||||
|
value={ollamaUrl}
|
||||||
|
onChange={(e) => setOllamaUrl(e.target.value)}
|
||||||
|
placeholder="http://192.168.68.108:11434"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={testConnection}>
|
||||||
|
{testStatus === "testing" ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : testStatus === "success" ? (
|
||||||
|
<Check className="mr-1.5 size-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Brain className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/app/(dashboard)/settings/general/page.tsx
Normal file
280
src/app/(dashboard)/settings/general/page.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
Bell,
|
||||||
|
HardDrive,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs";
|
||||||
|
|
||||||
|
type NotificationPrefs = {
|
||||||
|
processingComplete: boolean;
|
||||||
|
processingErrors: boolean;
|
||||||
|
folderWatchAlerts: boolean;
|
||||||
|
cleanupReminders: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||||
|
processingComplete: true,
|
||||||
|
processingErrors: true,
|
||||||
|
folderWatchAlerts: true,
|
||||||
|
cleanupReminders: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadNotificationPrefs(): NotificationPrefs {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
|
||||||
|
if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) };
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_NOTIFICATION_PREFS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeneralSettingsPage() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [sourceRetention, setSourceRetention] = React.useState(30);
|
||||||
|
const [imageRetention, setImageRetention] = React.useState(180);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [cleanupStatus, setCleanupStatus] = React.useState<{
|
||||||
|
sourcesEligible: number;
|
||||||
|
imagesEligible: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [cleaning, setCleaning] = React.useState(false);
|
||||||
|
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
|
||||||
|
DEFAULT_NOTIFICATION_PREFS
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNotifPrefs(loadNotificationPrefs());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setSourceRetention(data.sourceRetentionDays || 30);
|
||||||
|
setImageRetention(data.imageRetentionDays || 180);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
|
||||||
|
fetch("/api/cleanup")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
|
||||||
|
setNotifPrefs((prev) => {
|
||||||
|
const next = { ...prev, [key]: value };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast.success("Notification preference updated");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceRetentionDays: sourceRetention,
|
||||||
|
imageRetentionDays: imageRetention,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("Settings saved");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCleanup = async () => {
|
||||||
|
setCleaning(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/cleanup", { method: "POST" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(
|
||||||
|
`Cleaned ${data.sourcesDeleted ?? 0} sources and ${data.imagesDeleted ?? 0} images`
|
||||||
|
);
|
||||||
|
setCleanupStatus({ sourcesEligible: 0, imagesEligible: 0 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Cleanup failed");
|
||||||
|
} finally {
|
||||||
|
setCleaning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Appearance */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Sun className="size-4" />
|
||||||
|
Appearance
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: "light", icon: Sun, label: "Light" },
|
||||||
|
{ value: "dark", icon: Moon, label: "Dark" },
|
||||||
|
{ value: "system", icon: Monitor, label: "System" },
|
||||||
|
].map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
variant={theme === opt.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme(opt.value)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<opt.icon className="size-3.5" />
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["processingComplete", "Processing Complete"],
|
||||||
|
["processingErrors", "Processing Errors"],
|
||||||
|
["folderWatchAlerts", "Folder Watch Alerts"],
|
||||||
|
["cleanupReminders", "Cleanup Reminders"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<Switch
|
||||||
|
checked={notifPrefs[key]}
|
||||||
|
onCheckedChange={(v) => updateNotifPref(key, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Storage & Retention */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<HardDrive className="size-4" />
|
||||||
|
Storage & Retention
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Automatically clean up old files to save storage.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Source file retention (days)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={sourceRetention}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSourceRetention(parseInt(e.target.value) || 30)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Image retention (days)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={imageRetention}
|
||||||
|
onChange={(e) =>
|
||||||
|
setImageRetention(parseInt(e.target.value) || 180)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cleanupStatus && (
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{cleanupStatus.sourcesEligible} sources,{" "}
|
||||||
|
{cleanupStatus.imagesEligible} images eligible for cleanup
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={runCleanup}
|
||||||
|
disabled={cleaning}
|
||||||
|
>
|
||||||
|
{cleaning ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Run Cleanup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
406
src/app/(dashboard)/settings/integrations/[id]/page.tsx
Normal file
406
src/app/(dashboard)/settings/integrations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Plug,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Integration = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
fieldMapping: Record<string, string> | null;
|
||||||
|
triggerEvents: string[] | null;
|
||||||
|
syncDirection: string;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncStatus: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGER_EVENTS = [
|
||||||
|
{ value: "ocr_complete", label: "OCR Complete" },
|
||||||
|
{ value: "card_reviewed", label: "Card Reviewed" },
|
||||||
|
{ value: "card_exported", label: "Card Exported" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function IntegrationDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [integration, setIntegration] = React.useState<Integration | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [testing, setTesting] = React.useState(false);
|
||||||
|
const [testResult, setTestResult] = React.useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [syncing, setSyncing] = React.useState(false);
|
||||||
|
const [deleting, setDeleting] = React.useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch(`/api/integrations/${id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setIntegration(data.integration);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!integration) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: integration.name,
|
||||||
|
enabled: integration.enabled,
|
||||||
|
config: integration.config,
|
||||||
|
triggerEvents: integration.triggerEvents,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Integration saved");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to save");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/${id}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setTestResult(data);
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.message);
|
||||||
|
} else {
|
||||||
|
toast.error(data.message);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTestResult({ success: false, message: "Test request failed" });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/${id}/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(
|
||||||
|
`Synced ${data.synced} of ${data.total} cards${data.failed ? ` (${data.failed} failed)` : ""}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Sync failed");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Integration deleted");
|
||||||
|
router.push("/settings/integrations");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to delete");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (key: string, value: string) => {
|
||||||
|
setIntegration((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
config: { ...prev.config, [key]: value },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTriggerEvent = (event: string) => {
|
||||||
|
setIntegration((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const events = prev.triggerEvents || [];
|
||||||
|
const next = events.includes(event)
|
||||||
|
? events.filter((e) => e !== event)
|
||||||
|
: [...events, event];
|
||||||
|
return { ...prev, triggerEvents: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/settings/integrations")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1.5 size-3.5" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Integration not found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/settings/integrations")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1.5 size-3.5" />
|
||||||
|
Back to Integrations
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={integration.enabled}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setIntegration((prev) =>
|
||||||
|
prev ? { ...prev, enabled: v } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
{integration.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
integration.lastSyncStatus === "connected" ||
|
||||||
|
integration.lastSyncStatus === "success"
|
||||||
|
? "border-emerald-500/30 text-emerald-500"
|
||||||
|
: integration.lastSyncStatus === "error"
|
||||||
|
? "border-destructive/30 text-destructive"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{integration.lastSyncStatus === "connected" ||
|
||||||
|
integration.lastSyncStatus === "success" ? (
|
||||||
|
<CheckCircle2 className="mr-1 size-3" />
|
||||||
|
) : integration.lastSyncStatus === "error" ? (
|
||||||
|
<XCircle className="mr-1 size-3" />
|
||||||
|
) : null}
|
||||||
|
{integration.lastSyncStatus || "Not tested"}
|
||||||
|
</Badge>
|
||||||
|
{integration.lastSyncAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Last activity:{" "}
|
||||||
|
{new Date(integration.lastSyncAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plug className="size-4" />
|
||||||
|
Configuration
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Integration Name</Label>
|
||||||
|
<Input
|
||||||
|
value={integration.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntegration((prev) =>
|
||||||
|
prev ? { ...prev, name: e.target.value } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(integration.config).map(([key, value]) => {
|
||||||
|
if (key.startsWith("_") || key === "columnMap") return null;
|
||||||
|
const isSecret =
|
||||||
|
key.toLowerCase().includes("token") ||
|
||||||
|
key.toLowerCase().includes("secret") ||
|
||||||
|
key.toLowerCase().includes("password");
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label className="capitalize">
|
||||||
|
{key.replace(/([A-Z])/g, " $1").trim()}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type={isSecret ? "password" : "text"}
|
||||||
|
value={String(value || "")}
|
||||||
|
onChange={(e) => updateConfig(key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Trigger Events */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="size-4" />
|
||||||
|
Trigger Events
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
When should this integration fire?
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{TRIGGER_EVENTS.map((evt) => (
|
||||||
|
<div key={evt.value} className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
integration.triggerEvents?.includes(evt.value) ?? false
|
||||||
|
}
|
||||||
|
onCheckedChange={() => toggleTriggerEvent(evt.value)}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">{evt.label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleTest} disabled={testing}>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleSync} disabled={syncing}>
|
||||||
|
{syncing ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 text-sm ${
|
||||||
|
testResult.success
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "border-destructive/30 bg-destructive/10 text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="glass-card border-destructive/30">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Delete this integration
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
{confirmDelete ? "Confirm Delete" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/app/(dashboard)/settings/integrations/new/page.tsx
Normal file
246
src/app/(dashboard)/settings/integrations/new/page.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, ArrowLeft, Plug, Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type ProviderInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
configFields: ConfigField[];
|
||||||
|
supportsOAuth: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfigField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
helpText?: string;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewIntegrationPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const preselectedProvider = searchParams.get("provider") || "";
|
||||||
|
|
||||||
|
const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
|
||||||
|
const [selectedProvider, setSelectedProvider] = React.useState(preselectedProvider);
|
||||||
|
const [name, setName] = React.useState("");
|
||||||
|
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [creating, setCreating] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/integrations")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setProviders(data.providers || []);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedProvider) {
|
||||||
|
const p = providers.find((pr) => pr.id === selectedProvider);
|
||||||
|
if (p && !name) {
|
||||||
|
setName(p.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedProvider, providers, name]);
|
||||||
|
|
||||||
|
const provider = providers.find((p) => p.id === selectedProvider);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedProvider) {
|
||||||
|
toast.error("Select a provider");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/integrations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: selectedProvider,
|
||||||
|
name: name || provider?.name || selectedProvider,
|
||||||
|
config,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success("Integration created");
|
||||||
|
|
||||||
|
if (provider?.supportsOAuth) {
|
||||||
|
window.location.href = `/api/integrations/oauth/${selectedProvider}/authorize?integrationId=${data.integration.id}`;
|
||||||
|
} else {
|
||||||
|
router.push(`/settings/integrations/${data.integration.id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.error(data.error || "Failed to create integration");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to create integration");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/settings/integrations")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-1.5 size-3.5" />
|
||||||
|
Back to Integrations
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!selectedProvider ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Choose a provider</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedProvider(p.id)}
|
||||||
|
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-5 text-center transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<Plug className="size-6 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{p.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{p.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plug className="size-4" />
|
||||||
|
Configure {provider?.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{provider?.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Integration Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={provider?.name || "My Integration"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider?.configFields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{field.label}
|
||||||
|
{field.required && (
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{field.type === "select" && field.options ? (
|
||||||
|
<select
|
||||||
|
value={config[field.key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
[field.key]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={field.type === "password" ? "password" : "text"}
|
||||||
|
value={config[field.key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
[field.key]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.helpText && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{field.helpText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{provider?.supportsOAuth && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
After creating, you'll be redirected to authorize with{" "}
|
||||||
|
{provider.name}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSelectedProvider("")}
|
||||||
|
>
|
||||||
|
Change Provider
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={creating} className="flex-1">
|
||||||
|
{creating ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
{provider?.supportsOAuth
|
||||||
|
? "Create & Connect"
|
||||||
|
: "Create Integration"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/app/(dashboard)/settings/integrations/page.tsx
Normal file
205
src/app/(dashboard)/settings/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plug,
|
||||||
|
Plus,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Integration = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncStatus: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
category: string;
|
||||||
|
supportsOAuth: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, string> = {
|
||||||
|
planning_center: "PCO",
|
||||||
|
monday: "M",
|
||||||
|
airtable: "AT",
|
||||||
|
google_sheets: "GS",
|
||||||
|
webhook: "WH",
|
||||||
|
csv_export: "CSV",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURED_PROVIDERS = ["planning_center"];
|
||||||
|
|
||||||
|
export default function IntegrationsPage() {
|
||||||
|
const [integrations, setIntegrations] = React.useState<Integration[]>([]);
|
||||||
|
const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/integrations")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setIntegrations(data.integrations || []);
|
||||||
|
setProviders(data.providers || []);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedProviderIds = new Set(integrations.map((i) => i.provider));
|
||||||
|
const availableProviders = providers.filter(
|
||||||
|
(p) => !connectedProviderIds.has(p.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Connected Integrations */}
|
||||||
|
{integrations.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Connected
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{integrations.map((int) => {
|
||||||
|
const provider = providers.find((p) => p.id === int.provider);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={int.id}
|
||||||
|
href={`/settings/integrations/${int.id}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<Card className="glass-card transition-colors hover:border-primary/30">
|
||||||
|
<CardContent className="flex items-start gap-4 p-4">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
|
||||||
|
{PROVIDER_ICONS[int.provider] || "?"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{int.name}
|
||||||
|
</p>
|
||||||
|
{int.enabled ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-500/30 text-emerald-500"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 size-3" />
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
Disabled
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{provider?.name || int.provider}
|
||||||
|
{int.lastSyncAt && (
|
||||||
|
<>
|
||||||
|
{" · Last sync "}
|
||||||
|
{new Date(int.lastSyncAt).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Providers */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Available Integrations
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{(availableProviders.length > 0
|
||||||
|
? availableProviders
|
||||||
|
: providers
|
||||||
|
).map((provider) => {
|
||||||
|
const isConnected = connectedProviderIds.has(provider.id);
|
||||||
|
const isFeatured = FEATURED_PROVIDERS.includes(provider.id);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={provider.id}
|
||||||
|
className={`glass-card relative ${isFeatured ? "border-primary/30" : ""}`}
|
||||||
|
>
|
||||||
|
{isFeatured && (
|
||||||
|
<span className="absolute -top-2.5 left-4 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-primary-foreground">
|
||||||
|
RECOMMENDED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 p-5 text-center">
|
||||||
|
<div
|
||||||
|
className={`flex size-12 items-center justify-center rounded-xl text-sm font-bold ${
|
||||||
|
isFeatured
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PROVIDER_ICONS[provider.id] || <Plug className="size-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{provider.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{provider.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isConnected ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-500/30 text-emerald-500"
|
||||||
|
>
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Link href={`/settings/integrations/new?provider=${provider.id}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/(dashboard)/settings/layout.tsx
Normal file
66
src/app/(dashboard)/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Upload,
|
||||||
|
Brain,
|
||||||
|
Plug,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ href: "/settings/general", label: "General", icon: Settings },
|
||||||
|
{ href: "/settings/upload-sources", label: "Upload Sources", icon: Upload },
|
||||||
|
{ href: "/settings/ai", label: "AI Provider", icon: Brain },
|
||||||
|
{ href: "/settings/integrations", label: "Integrations", icon: Plug },
|
||||||
|
{ href: "/settings/users", label: "Team Members", icon: Users },
|
||||||
|
{ href: "/settings/locations", label: "Locations", icon: MapPin },
|
||||||
|
{ href: "/settings/organization", label: "Organization", icon: Building2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="Settings" />
|
||||||
|
<div className="mt-6 flex flex-col gap-6 lg:flex-row">
|
||||||
|
<nav className="flex gap-1 overflow-x-auto lg:w-56 lg:shrink-0 lg:flex-col">
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const active =
|
||||||
|
pathname === section.href ||
|
||||||
|
(section.href !== "/settings/general" &&
|
||||||
|
pathname.startsWith(section.href));
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={section.href}
|
||||||
|
href={section.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,200 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MapPin } from "lucide-react";
|
import * as React from "react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
MapPin,
|
||||||
|
CalendarDays,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Location = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
collectionDays: CollectionDay[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollectionDay = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dayOfWeek: number | null;
|
||||||
|
timeStart: string | null;
|
||||||
|
timeEnd: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
];
|
||||||
|
|
||||||
export default function LocationsSettingsPage() {
|
export default function LocationsSettingsPage() {
|
||||||
|
const [locations, setLocations] = React.useState<Location[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [newLocName, setNewLocName] = React.useState("");
|
||||||
|
const [newLocAddress, setNewLocAddress] = React.useState("");
|
||||||
|
const [adding, setAdding] = React.useState(false);
|
||||||
|
|
||||||
|
const fetchData = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/org/locations");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setLocations(data.locations || []);
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleAddLocation = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/org/locations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newLocName, address: newLocAddress }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Location added");
|
||||||
|
setNewLocName("");
|
||||||
|
setNewLocAddress("");
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to add location");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to add location");
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Header
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
title="Locations"
|
</div>
|
||||||
description="Manage your organization's locations and campuses"
|
);
|
||||||
icon={MapPin}
|
}
|
||||||
/>
|
|
||||||
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
return (
|
||||||
Location management will be available after the org model is set up.
|
<div className="space-y-6">
|
||||||
</div>
|
{/* Add Location */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Location
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={handleAddLocation}
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={newLocName}
|
||||||
|
onChange={(e) => setNewLocName(e.target.value)}
|
||||||
|
placeholder="Main Campus"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||||
|
<Label className="text-xs">Address (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={newLocAddress}
|
||||||
|
onChange={(e) => setNewLocAddress(e.target.value)}
|
||||||
|
placeholder="123 Church St"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={adding}>
|
||||||
|
{adding ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Existing Locations */}
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<Card key={loc.id} className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<MapPin className="size-4" />
|
||||||
|
{loc.name}
|
||||||
|
</CardTitle>
|
||||||
|
{loc.address && (
|
||||||
|
<CardDescription>{loc.address}</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
Collection Days
|
||||||
|
</p>
|
||||||
|
{loc.collectionDays.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{loc.collectionDays.map((cd) => (
|
||||||
|
<div
|
||||||
|
key={cd.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{cd.name}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{cd.dayOfWeek != null && DAYS[cd.dayOfWeek]}
|
||||||
|
{cd.timeStart && ` ${cd.timeStart}`}
|
||||||
|
{cd.timeEnd && `–${cd.timeEnd}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No collection days configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{locations.length === 0 && (
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No locations yet — add one above.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,190 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Building2 } from "lucide-react";
|
import * as React from "react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Save, Building2, Globe, AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const ORG_TYPES = [
|
||||||
|
{ value: "church", label: "Church" },
|
||||||
|
{ value: "ministry", label: "Ministry" },
|
||||||
|
{ value: "nonprofit", label: "Nonprofit" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type OrgData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function OrganizationSettingsPage() {
|
export default function OrganizationSettingsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [org, setOrg] = React.useState<OrgData | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/org")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.organization) setOrg(data.organization);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!org) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/org", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: org.name,
|
||||||
|
type: org.type,
|
||||||
|
timezone: org.timezone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success("Organization updated");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to update organization");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update organization");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Header
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
title="Organization"
|
</div>
|
||||||
description="Manage your organization details and preferences"
|
);
|
||||||
icon={Building2}
|
}
|
||||||
/>
|
|
||||||
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
if (!org) {
|
||||||
Organization settings will be available after the org model is set up.
|
return (
|
||||||
</div>
|
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No organization found. Complete onboarding to set up your organization.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Building2 className="size-4" />
|
||||||
|
Organization Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Organization Name</Label>
|
||||||
|
<Input
|
||||||
|
value={org.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrg((prev) => (prev ? { ...prev, name: e.target.value } : prev))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URL Slug</Label>
|
||||||
|
<Input value={org.slug} disabled className="opacity-60" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The slug is auto-generated and cannot be changed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select
|
||||||
|
value={org.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v && setOrg((prev) => (prev ? { ...prev, type: v } : prev))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ORG_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Timezone</Label>
|
||||||
|
<Input
|
||||||
|
value={org.timezone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrg((prev) =>
|
||||||
|
prev ? { ...prev, timezone: e.target.value } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="glass-card border-destructive/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base text-destructive">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These actions are irreversible and affect all members.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="destructive" size="sm" disabled>
|
||||||
|
Delete Organization
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Organization deletion is not yet available. Contact support.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
358
src/app/(dashboard)/settings/upload-sources/page.tsx
Normal file
358
src/app/(dashboard)/settings/upload-sources/page.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Mail,
|
||||||
|
FolderSearch,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type EmailSettings = {
|
||||||
|
emailImapHost: string;
|
||||||
|
emailImapPort: number;
|
||||||
|
emailImapUser: string;
|
||||||
|
emailImapPass: string;
|
||||||
|
emailImapTls: boolean;
|
||||||
|
emailFolder: string;
|
||||||
|
emailWatching: boolean;
|
||||||
|
emailProcessed: string;
|
||||||
|
emailProcessedFolder: string;
|
||||||
|
watchDir: string;
|
||||||
|
watching: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UploadSourcesPage() {
|
||||||
|
const [settings, setSettings] = React.useState<EmailSettings>({
|
||||||
|
emailImapHost: "",
|
||||||
|
emailImapPort: 993,
|
||||||
|
emailImapUser: "",
|
||||||
|
emailImapPass: "",
|
||||||
|
emailImapTls: true,
|
||||||
|
emailFolder: "INBOX",
|
||||||
|
emailWatching: false,
|
||||||
|
emailProcessed: "mark_read",
|
||||||
|
emailProcessedFolder: "Processed",
|
||||||
|
watchDir: "",
|
||||||
|
watching: false,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [emailTestStatus, setEmailTestStatus] = React.useState<
|
||||||
|
"idle" | "testing" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [scanning, setScanning] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setSettings({
|
||||||
|
emailImapHost: data.emailImapHost || "",
|
||||||
|
emailImapPort: data.emailImapPort || 993,
|
||||||
|
emailImapUser: data.emailImapUser || "",
|
||||||
|
emailImapPass: data.emailImapPass || "",
|
||||||
|
emailImapTls: data.emailImapTls ?? true,
|
||||||
|
emailFolder: data.emailFolder || "INBOX",
|
||||||
|
emailWatching: data.emailWatching || false,
|
||||||
|
emailProcessed: data.emailProcessed || "mark_read",
|
||||||
|
emailProcessedFolder: data.emailProcessedFolder || "Processed",
|
||||||
|
watchDir: data.watchDir || "",
|
||||||
|
watching: data.watching || false,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast.success("Upload source settings saved");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testEmail = async () => {
|
||||||
|
setEmailTestStatus("testing");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/email/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
setEmailTestStatus(res.ok ? "success" : "error");
|
||||||
|
if (res.ok) toast.success("Email connection successful");
|
||||||
|
else toast.error("Email connection failed");
|
||||||
|
} catch {
|
||||||
|
setEmailTestStatus("error");
|
||||||
|
toast.error("Email test failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanNow = async () => {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/email/scan", { method: "POST" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(`Scanned ${data.processed || 0} emails`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Scan failed");
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFolderWatch = async () => {
|
||||||
|
const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, { method: "POST" });
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings((s) => ({ ...s, watching: !s.watching }));
|
||||||
|
toast.success(
|
||||||
|
settings.watching ? "Folder watch stopped" : "Folder watch started"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to toggle folder watch");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Email Inbox */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
Email Inbox
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Auto-import response cards from an email inbox via IMAP.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IMAP Host</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.emailImapHost}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, emailImapHost: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="imap.gmail.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={settings.emailImapPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
emailImapPort: parseInt(e.target.value) || 993,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.emailImapUser}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, emailImapUser: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={settings.emailImapPass}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, emailImapPass: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={settings.emailImapTls}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSettings((s) => ({ ...s, emailImapTls: 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>Folder</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.emailFolder}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, emailFolder: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>After Processing</Label>
|
||||||
|
<Select
|
||||||
|
value={settings.emailProcessed}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
v && setSettings((s) => ({ ...s, emailProcessed: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="mark_read">Mark as read</SelectItem>
|
||||||
|
<SelectItem value="move">Move to folder</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={testEmail}>
|
||||||
|
{emailTestStatus === "testing" ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : emailTestStatus === "success" ? (
|
||||||
|
<Check className="mr-1.5 size-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Mail className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={scanNow} disabled={scanning}>
|
||||||
|
{scanning ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Scan Now
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={settings.emailWatching}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSettings((s) => ({ ...s, emailWatching: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Auto-watch</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Folder Watch */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FolderSearch className="size-4" />
|
||||||
|
Folder Watch
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Monitor a local directory for new scanned files.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Watch Directory</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.watchDir}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, watchDir: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="/mnt/scans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleFolderWatch}
|
||||||
|
>
|
||||||
|
{settings.watching ? (
|
||||||
|
<>
|
||||||
|
<WifiOff className="mr-1.5 size-3.5" />
|
||||||
|
Stop Watching
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi className="mr-1.5 size-3.5" />
|
||||||
|
Start Watching
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{settings.watching && (
|
||||||
|
<Badge variant="outline" className="text-emerald-500">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,243 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserCog } from "lucide-react";
|
import * as React from "react";
|
||||||
import { Header } from "@/components/layout/header";
|
import { toast } from "sonner";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Loader2, Plus, UserPlus, Shield, Trash2, Mail } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Member = {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
user: { id: string; email: string; displayName: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Invitation = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
expiresAt: string;
|
||||||
|
acceptedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: "viewer", label: "Viewer" },
|
||||||
|
{ value: "reviewer", label: "Reviewer" },
|
||||||
|
{ value: "editor", label: "Editor" },
|
||||||
|
{ value: "admin", label: "Admin" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function UsersSettingsPage() {
|
export default function UsersSettingsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [members, setMembers] = React.useState<Member[]>([]);
|
||||||
|
const [invitations, setInvitations] = React.useState<Invitation[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [inviteEmail, setInviteEmail] = React.useState("");
|
||||||
|
const [inviteRole, setInviteRole] = React.useState("viewer");
|
||||||
|
const [sending, setSending] = React.useState(false);
|
||||||
|
|
||||||
|
const fetchData = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [membersRes, invitesRes] = await Promise.all([
|
||||||
|
fetch("/api/org/members"),
|
||||||
|
fetch("/api/org/invitations"),
|
||||||
|
]);
|
||||||
|
if (membersRes.ok) {
|
||||||
|
const data = await membersRes.json();
|
||||||
|
setMembers(data.members || []);
|
||||||
|
}
|
||||||
|
if (invitesRes.ok) {
|
||||||
|
const data = await invitesRes.json();
|
||||||
|
setInvitations(data.invitations || []);
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/org/invitations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(`Invitation sent to ${inviteEmail}`);
|
||||||
|
setInviteEmail("");
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.error(data.error || "Failed to send invitation");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to send invitation");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Header
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
title="Users"
|
</div>
|
||||||
description="Manage team members and invitations"
|
);
|
||||||
icon={UserCog}
|
}
|
||||||
/>
|
|
||||||
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
|
return (
|
||||||
User management and invitations will be available after the auth system
|
<div className="space-y-6">
|
||||||
is set up.
|
{/* Invite */}
|
||||||
</div>
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
Invite Team Member
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-[200px] flex-1 space-y-1.5">
|
||||||
|
<Label className="text-xs">Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
placeholder="colleague@church.org"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 space-y-1.5">
|
||||||
|
<Label className="text-xs">Role</Label>
|
||||||
|
<Select value={inviteRole} onValueChange={(v) => v && setInviteRole(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<SelectItem key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={sending}>
|
||||||
|
{sending ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Members */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Shield className="size-4" />
|
||||||
|
Team Members
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{members.length} member{members.length !== 1 && "s"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{m.user.displayName || m.user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{m.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{m.role}
|
||||||
|
</Badge>
|
||||||
|
{m.user.id === session?.user?.id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
You
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No team members yet — send an invitation above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pending Invitations */}
|
||||||
|
{invitations.length > 0 && (
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
Pending Invitations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invitations
|
||||||
|
.filter((i) => !i.acceptedAt)
|
||||||
|
.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border/50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{i.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Expires{" "}
|
||||||
|
{new Date(i.expiresAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{i.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sendVerificationEmail } from "@/lib/email-sender";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -94,6 +95,13 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = new URL(req.url).origin;
|
||||||
|
await sendVerificationEmail(email, baseUrl);
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.warn("[register] Verification email failed (non-blocking):", emailErr);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, userId: user.id });
|
return NextResponse.json({ success: true, userId: user.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[register] Error:", error);
|
console.error("[register] Error:", error);
|
||||||
|
|
|
||||||
40
src/app/api/auth/verify-email/confirm/route.ts
Normal file
40
src/app/api/auth/verify-email/confirm/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = req.nextUrl.searchParams.get("token");
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=missing_token", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.verificationToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=invalid_token", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.expires < new Date()) {
|
||||||
|
await prisma.verificationToken.delete({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
return NextResponse.redirect(new URL("/login?error=expired_token", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email: record.identifier },
|
||||||
|
data: { emailVerified: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.verificationToken.delete({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/?verified=true", req.url));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[verify-email/confirm] Error:", error);
|
||||||
|
return NextResponse.redirect(new URL("/login?error=verification_failed", req.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/verify-email/send/route.ts
Normal file
37
src/app/api/auth/verify-email/send/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { sendVerificationEmail } from "@/lib/email-sender";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { email: true, emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = new URL(req.url).origin;
|
||||||
|
await sendVerificationEmail(user.email, baseUrl);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[verify-email/send] Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to send verification email" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/integrations/[id]/fields/route.ts
Normal file
42
src/app/api/integrations/[id]/fields/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const integration = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProvider(integration.provider);
|
||||||
|
if (!provider?.getExternalFields) {
|
||||||
|
return NextResponse.json({ fields: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = await provider.getExternalFields(integration.config);
|
||||||
|
return NextResponse.json({ fields });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]/fields] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch fields" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/api/integrations/[id]/route.ts
Normal file
115
src/app/api/integrations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const integration = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ integration });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch integration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { config, fieldMapping, enabled, triggerEvents, name, syncDirection } =
|
||||||
|
body;
|
||||||
|
|
||||||
|
const integration = await prisma.integration.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(config !== undefined && { config }),
|
||||||
|
...(fieldMapping !== undefined && { fieldMapping }),
|
||||||
|
...(enabled !== undefined && { enabled }),
|
||||||
|
...(triggerEvents !== undefined && { triggerEvents }),
|
||||||
|
...(name !== undefined && { name }),
|
||||||
|
...(syncDirection !== undefined && { syncDirection }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ integration });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]] PUT error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update integration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.integration.delete({ where: { id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]] DELETE error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete integration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/integrations/[id]/sync/route.ts
Normal file
90
src/app/api/integrations/[id]/sync/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
import type { CardData } from "@/lib/integrations/types";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const integration = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProvider(integration.provider);
|
||||||
|
if (!provider) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unknown provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const cardIds = body.cardIds as string[] | undefined;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
};
|
||||||
|
if (cardIds?.length) {
|
||||||
|
where.id = { in: cardIds };
|
||||||
|
} else {
|
||||||
|
where.reviewStatus = "reviewed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await prisma.responseCard.findMany({
|
||||||
|
where,
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
const result = await provider.pushCard(
|
||||||
|
card as unknown as CardData,
|
||||||
|
integration.config,
|
||||||
|
integration.fieldMapping
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.integration.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
lastSyncStatus: failCount === 0 ? "success" : "partial",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
synced: successCount,
|
||||||
|
failed: failCount,
|
||||||
|
total: cards.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]/sync] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Sync failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/integrations/[id]/test/route.ts
Normal file
54
src/app/api/integrations/[id]/test/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const integration = await prisma.integration.findFirst({
|
||||||
|
where: { id, organizationId: session.user.orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Integration not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProvider(integration.provider);
|
||||||
|
if (!provider) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unknown provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await provider.testConnection(integration.config);
|
||||||
|
|
||||||
|
await prisma.integration.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
lastSyncStatus: result.success ? "connected" : "error",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations/[id]/test] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Test failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/integrations/oauth/[provider]/authorize/route.ts
Normal file
80
src/app/api/integrations/oauth/[provider]/authorize/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const OAUTH_CONFIGS: Record<
|
||||||
|
string,
|
||||||
|
{ authUrl: string; clientIdEnv: string; scopes: string }
|
||||||
|
> = {
|
||||||
|
planning_center: {
|
||||||
|
authUrl: "https://api.planningcenteronline.com/oauth/authorize",
|
||||||
|
clientIdEnv: "PCO_CLIENT_ID",
|
||||||
|
scopes: "people",
|
||||||
|
},
|
||||||
|
google_sheets: {
|
||||||
|
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
clientIdEnv: "GOOGLE_CLIENT_ID",
|
||||||
|
scopes: "https://www.googleapis.com/auth/spreadsheets",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ provider: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider } = await params;
|
||||||
|
const oauthConfig = OAUTH_CONFIGS[provider];
|
||||||
|
if (!oauthConfig) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `OAuth not supported for provider: ${provider}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env[oauthConfig.clientIdEnv];
|
||||||
|
if (!clientId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `${provider} OAuth not configured — set ${oauthConfig.clientIdEnv} in environment`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationId = req.nextUrl.searchParams.get("integrationId") || "";
|
||||||
|
const baseUrl = new URL(req.url).origin;
|
||||||
|
const redirectUri = `${baseUrl}/api/integrations/oauth/${provider}/callback`;
|
||||||
|
|
||||||
|
const state = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
orgId: session.user.orgId,
|
||||||
|
integrationId,
|
||||||
|
})
|
||||||
|
).toString("base64url");
|
||||||
|
|
||||||
|
const authUrl = new URL(oauthConfig.authUrl);
|
||||||
|
authUrl.searchParams.set("client_id", clientId);
|
||||||
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
authUrl.searchParams.set("response_type", "code");
|
||||||
|
authUrl.searchParams.set("scope", oauthConfig.scopes);
|
||||||
|
authUrl.searchParams.set("state", state);
|
||||||
|
|
||||||
|
if (provider === "google_sheets") {
|
||||||
|
authUrl.searchParams.set("access_type", "offline");
|
||||||
|
authUrl.searchParams.set("prompt", "consent");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(authUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[oauth/authorize] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "OAuth authorization failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/api/integrations/oauth/[provider]/callback/route.ts
Normal file
124
src/app/api/integrations/oauth/[provider]/callback/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
const OAUTH_CONFIGS: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
tokenUrl: string;
|
||||||
|
clientIdEnv: string;
|
||||||
|
clientSecretEnv: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
planning_center: {
|
||||||
|
tokenUrl: "https://api.planningcenteronline.com/oauth/token",
|
||||||
|
clientIdEnv: "PCO_CLIENT_ID",
|
||||||
|
clientSecretEnv: "PCO_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
google_sheets: {
|
||||||
|
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||||
|
clientIdEnv: "GOOGLE_CLIENT_ID",
|
||||||
|
clientSecretEnv: "GOOGLE_CLIENT_SECRET",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ provider: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { provider } = await params;
|
||||||
|
const oauthConfig = OAUTH_CONFIGS[provider];
|
||||||
|
if (!oauthConfig) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/settings/integrations?error=unsupported_provider", req.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = req.nextUrl.searchParams.get("code");
|
||||||
|
const stateParam = req.nextUrl.searchParams.get("state");
|
||||||
|
|
||||||
|
if (!code || !stateParam) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/settings/integrations?error=missing_code", req.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: { orgId: string; integrationId: string };
|
||||||
|
try {
|
||||||
|
state = JSON.parse(Buffer.from(stateParam, "base64url").toString());
|
||||||
|
} catch {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/settings/integrations?error=invalid_state", req.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env[oauthConfig.clientIdEnv] || "";
|
||||||
|
const clientSecret = process.env[oauthConfig.clientSecretEnv] || "";
|
||||||
|
const baseUrl = new URL(req.url).origin;
|
||||||
|
const redirectUri = `${baseUrl}/api/integrations/oauth/${provider}/callback`;
|
||||||
|
|
||||||
|
const tokenRes = await fetch(oauthConfig.tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const text = await tokenRes.text().catch(() => "");
|
||||||
|
console.error("[oauth/callback] Token exchange failed:", text);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/settings/integrations?error=token_exchange_failed", req.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenRes.json();
|
||||||
|
|
||||||
|
if (state.integrationId) {
|
||||||
|
const integration = await prisma.integration.findFirst({
|
||||||
|
where: {
|
||||||
|
id: state.integrationId,
|
||||||
|
organizationId: state.orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (integration) {
|
||||||
|
const existingConfig =
|
||||||
|
(integration.config as Record<string, unknown>) || {};
|
||||||
|
await prisma.integration.update({
|
||||||
|
where: { id: integration.id },
|
||||||
|
data: {
|
||||||
|
config: {
|
||||||
|
...existingConfig,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token || existingConfig.refreshToken,
|
||||||
|
tokenExpiresAt: tokens.expires_in
|
||||||
|
? Date.now() + tokens.expires_in * 1000
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
lastSyncStatus: "connected",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/settings/integrations${state.integrationId ? `/${state.integrationId}` : ""}?connected=true`,
|
||||||
|
req.url
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[oauth/callback] error:", error);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/settings/integrations?error=callback_failed", req.url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/integrations/route.ts
Normal file
76
src/app/api/integrations/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getAllProviders } from "@/lib/integrations/registry";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrations = await prisma.integration.findMany({
|
||||||
|
where: { organizationId: session.user.orgId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = getAllProviders().map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
icon: p.icon,
|
||||||
|
category: p.category,
|
||||||
|
supportsOAuth: p.supportsOAuth,
|
||||||
|
supportsFieldMapping: p.supportsFieldMapping,
|
||||||
|
configFields: p.configFields,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ integrations, providers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch integrations" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.orgId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { provider, name, config, fieldMapping, triggerEvents } = body;
|
||||||
|
|
||||||
|
if (!provider || !name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider and name are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const integration = await prisma.integration.create({
|
||||||
|
data: {
|
||||||
|
organizationId: session.user.orgId,
|
||||||
|
provider,
|
||||||
|
name,
|
||||||
|
config: config || {},
|
||||||
|
fieldMapping: fieldMapping || null,
|
||||||
|
triggerEvents: triggerEvents || ["card_reviewed"],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ integration });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[integrations] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create integration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
353
src/app/api/onboarding/route.ts
Normal file
353
src/app/api/onboarding/route.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await prisma.orgMember.findFirst({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
onboardingComplete: true,
|
||||||
|
onboardingStep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json({
|
||||||
|
onboardingComplete: false,
|
||||||
|
currentStep: 0,
|
||||||
|
orgId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
onboardingComplete: membership.organization.onboardingComplete,
|
||||||
|
currentStep: membership.organization.onboardingStep,
|
||||||
|
orgId: membership.organizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch onboarding status" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { step, data } = body;
|
||||||
|
|
||||||
|
const membership = await prisma.orgMember.findFirst({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: { id: true, onboardingComplete: true, onboardingStep: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
case 1: {
|
||||||
|
const { name, type, timezone } = data;
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Organization name is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug: slugify(name),
|
||||||
|
type: type || "church",
|
||||||
|
timezone: timezone || "America/Chicago",
|
||||||
|
onboardingStep: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
nextStep: 2,
|
||||||
|
orgId: membership.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await prisma.organization.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug: slugify(name),
|
||||||
|
type: type || "church",
|
||||||
|
timezone: timezone || "America/Chicago",
|
||||||
|
onboardingStep: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.orgMember.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
organizationId: org.id,
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 2, orgId: org.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 2: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete step 1 first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locations } = data;
|
||||||
|
if (!locations || !Array.isArray(locations) || locations.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "At least one location is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const loc of locations) {
|
||||||
|
if (!loc.name) continue;
|
||||||
|
const existing = await prisma.location.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: membership.organizationId,
|
||||||
|
name: loc.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.location.create({
|
||||||
|
data: {
|
||||||
|
name: loc.name,
|
||||||
|
address: loc.address || null,
|
||||||
|
organizationId: membership.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 3: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete previous steps first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { services } = data;
|
||||||
|
if (services && Array.isArray(services)) {
|
||||||
|
const locations = await prisma.location.findMany({
|
||||||
|
where: { organizationId: membership.organizationId },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
const locationId = locations[0]?.id;
|
||||||
|
|
||||||
|
if (locationId) {
|
||||||
|
for (const svc of services) {
|
||||||
|
if (!svc.name) continue;
|
||||||
|
await prisma.collectionDay.create({
|
||||||
|
data: {
|
||||||
|
locationId,
|
||||||
|
name: svc.name,
|
||||||
|
dayOfWeek: svc.dayOfWeek ?? 0,
|
||||||
|
timeStart: svc.timeStart || null,
|
||||||
|
timeEnd: svc.timeEnd || null,
|
||||||
|
isRecurring: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 4: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete previous steps first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadSource } = data;
|
||||||
|
|
||||||
|
if (uploadSource === "email" && data.emailConfig) {
|
||||||
|
await prisma.appSettings.upsert({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
update: {
|
||||||
|
emailImapHost: data.emailConfig.host || "",
|
||||||
|
emailImapPort: data.emailConfig.port || 993,
|
||||||
|
emailImapUser: data.emailConfig.user || "",
|
||||||
|
emailImapPass: data.emailConfig.pass || "",
|
||||||
|
emailImapTls: data.emailConfig.tls ?? true,
|
||||||
|
},
|
||||||
|
create: { id: "singleton" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSource === "folder" && data.watchDir) {
|
||||||
|
await prisma.appSettings.upsert({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
update: { watchDir: data.watchDir },
|
||||||
|
create: { id: "singleton" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSource === "api") {
|
||||||
|
const crypto = await import("crypto");
|
||||||
|
const rawKey = `ek_${crypto.randomBytes(24).toString("hex")}`;
|
||||||
|
const hashedKey = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(rawKey)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name: "Onboarding API Key",
|
||||||
|
hashedKey,
|
||||||
|
prefix: rawKey.slice(0, 7),
|
||||||
|
organizationId: membership.organizationId,
|
||||||
|
permissions: ["cards:create", "cards:read"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 5, apiKey: rawKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 5: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete previous steps first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { aiProvider, aiModel } = data;
|
||||||
|
await prisma.appSettings.upsert({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
update: {
|
||||||
|
aiProvider: aiProvider || "gateway",
|
||||||
|
aiModel: aiModel || "",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: "singleton",
|
||||||
|
aiProvider: aiProvider || "gateway",
|
||||||
|
aiModel: aiModel || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 6: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete previous steps first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration selection is optional — just advance step
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingStep: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ nextStep: 7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 7: {
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete previous steps first" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.organization.update({
|
||||||
|
where: { id: membership.organizationId },
|
||||||
|
data: { onboardingComplete: true, onboardingStep: 7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
update: { isSetupComplete: true },
|
||||||
|
create: { id: "singleton", isSetupComplete: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ complete: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid step" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[onboarding] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Onboarding step failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/auth.ts
15
src/auth.ts
|
|
@ -63,19 +63,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
include: {
|
include: {
|
||||||
memberships: {
|
memberships: {
|
||||||
take: 1,
|
take: 1,
|
||||||
include: { organization: { select: { id: true, name: true } } },
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: { id: true, name: true, onboardingComplete: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
token.role = dbUser.role;
|
token.role = dbUser.role;
|
||||||
token.displayName = dbUser.displayName;
|
token.displayName = dbUser.displayName ?? undefined;
|
||||||
token.avatarUrl = dbUser.avatarUrl;
|
token.avatarUrl = dbUser.avatarUrl ?? undefined;
|
||||||
|
token.isEmailVerified = !!dbUser.emailVerified;
|
||||||
if (dbUser.memberships[0]) {
|
if (dbUser.memberships[0]) {
|
||||||
token.orgId = dbUser.memberships[0].organizationId;
|
token.orgId = dbUser.memberships[0].organizationId;
|
||||||
token.orgName = dbUser.memberships[0].organization.name;
|
token.orgName = dbUser.memberships[0].organization.name;
|
||||||
token.orgRole = dbUser.memberships[0].role;
|
token.orgRole = dbUser.memberships[0].role;
|
||||||
|
token.onboardingComplete =
|
||||||
|
dbUser.memberships[0].organization.onboardingComplete ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +97,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
session.user.orgName = token.orgName as string | undefined;
|
session.user.orgName = token.orgName as string | undefined;
|
||||||
session.user.displayName = token.displayName as string | undefined;
|
session.user.displayName = token.displayName as string | undefined;
|
||||||
session.user.avatarUrl = token.avatarUrl as string | undefined;
|
session.user.avatarUrl = token.avatarUrl as string | undefined;
|
||||||
|
session.user.isEmailVerified = token.isEmailVerified ?? false;
|
||||||
|
session.user.onboardingComplete = token.onboardingComplete ?? false;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -337,9 +337,9 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader onContextMenu={handleHeaderContextMenu}>
|
<TableHeader onContextMenu={handleHeaderContextMenu} className="bg-muted/40">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
const colMeta = header.column.columnDef.meta as Record<string, unknown> | undefined;
|
const colMeta = header.column.columnDef.meta as Record<string, unknown> | undefined;
|
||||||
const isSticky = header.column.id === "select" || !!colMeta?.sticky;
|
const isSticky = header.column.id === "select" || !!colMeta?.sticky;
|
||||||
|
|
@ -348,7 +348,7 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground",
|
"px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground",
|
||||||
isSticky && "sticky left-0 z-20 bg-background"
|
isSticky && "sticky left-0 z-20 bg-muted/40"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleHeaderClick(e, header.column.id)}
|
onClick={(e) => handleHeaderClick(e, header.column.id)}
|
||||||
>
|
>
|
||||||
|
|
@ -420,7 +420,7 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
ref={cellRef}
|
ref={cellRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-3 relative group/cell",
|
"px-4 py-3 relative group/cell",
|
||||||
isSticky && "sticky left-0 z-10 bg-background",
|
isSticky && "sticky left-0 z-10 bg-card",
|
||||||
isCopied && "ring-2 ring-primary/40 ring-inset"
|
isCopied && "ring-2 ring-primary/40 ring-inset"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export function Filters({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={visitType || null}
|
value={visitType || null}
|
||||||
onValueChange={(v: string | null) =>
|
onValueChange={(v: string | null) =>
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
|
<div className="grid grid-cols-3 gap-2 sm:gap-3 md:grid-cols-5">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
const isActive = activeFilter === card.filterKey;
|
const isActive = activeFilter === card.filterKey;
|
||||||
|
|
@ -116,26 +116,24 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClick(card.filterKey)}
|
onClick={() => handleClick(card.filterKey)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"gradient-stat flex flex-col gap-3 rounded-2xl p-4 text-left transition-all sm:p-5",
|
"gradient-stat flex flex-col gap-2 rounded-xl p-3 text-left transition-all sm:rounded-2xl sm:p-4",
|
||||||
"hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]",
|
"hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]",
|
||||||
isActive && `ring-2 ${card.activeRing} shadow-lg`
|
isActive && `ring-2 ${card.activeRing} shadow-lg`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 items-center justify-center rounded-xl",
|
"flex size-8 items-center justify-center rounded-lg sm:size-9 sm:rounded-xl",
|
||||||
card.accentClass
|
card.accentClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" />
|
<Icon className="size-4 sm:size-5" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold tracking-tight sm:text-3xl">
|
<p className="text-lg font-bold tracking-tight sm:text-2xl">
|
||||||
{stats ? card.value.toLocaleString() : "—"}
|
{stats ? card.value.toLocaleString() : "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground sm:text-sm">
|
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||||
{card.label}
|
{card.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
import { TopBar } from "@/components/layout/top-bar";
|
||||||
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
||||||
|
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
||||||
|
|
||||||
function ShellContent({ children }: { children: React.ReactNode }) {
|
function ShellContent({ children }: { children: React.ReactNode }) {
|
||||||
const { collapsed } = useSidebar();
|
const { collapsed } = useSidebar();
|
||||||
|
|
@ -14,11 +15,14 @@ function ShellContent({ children }: { children: React.ReactNode }) {
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 pt-16 transition-[margin-left] duration-200 ease-in-out",
|
"relative z-0 pt-16 transition-[margin-left] duration-200 ease-in-out",
|
||||||
collapsed ? "ml-16" : "ml-60"
|
collapsed ? "ml-16" : "ml-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
|
<div className="p-4 sm:p-6 lg:p-8">
|
||||||
|
<EmailVerificationBanner />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
60
src/components/layout/email-verification-banner.tsx
Normal file
60
src/components/layout/email-verification-banner.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Mail, X, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function EmailVerificationBanner() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
if (!session?.user || session.user.isEmailVerified || dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/verify-email/send", { method: "POST" });
|
||||||
|
if (res.ok) setSent(true);
|
||||||
|
} catch {
|
||||||
|
// silent fail — user can try again
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||||
|
<Mail className="size-4 shrink-0 text-amber-400" />
|
||||||
|
<p className="flex-1">
|
||||||
|
{sent ? (
|
||||||
|
"Verification email sent — check your inbox."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Please verify your email address.{" "}
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={sending}
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-amber-400 underline underline-offset-2 hover:text-amber-300 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{sending && <Loader2 className="size-3 animate-spin" />}
|
||||||
|
Resend verification email
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 shrink-0 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/lib/email-sender.ts
Normal file
77
src/lib/email-sender.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
function getTransporter() {
|
||||||
|
const host = process.env.SMTP_HOST || process.env.EMAIL_IMAP_HOST || "";
|
||||||
|
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||||
|
const user = process.env.SMTP_USER || process.env.EMAIL_IMAP_USER || "";
|
||||||
|
const pass = process.env.SMTP_PASS || process.env.EMAIL_IMAP_PASS || "";
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: { user, pass },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromAddress(): string {
|
||||||
|
return (
|
||||||
|
process.env.SMTP_FROM ||
|
||||||
|
process.env.SMTP_USER ||
|
||||||
|
process.env.EMAIL_IMAP_USER ||
|
||||||
|
"noreply@echoocr.app"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(to: string, subject: string, html: string) {
|
||||||
|
const transporter = getTransporter();
|
||||||
|
return transporter.sendMail({
|
||||||
|
from: getFromAddress(),
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateVerificationToken(email: string): Promise<string> {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||||
|
|
||||||
|
await prisma.verificationToken.deleteMany({
|
||||||
|
where: { identifier: email },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: email,
|
||||||
|
token,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerificationEmail(email: string, baseUrl: string) {
|
||||||
|
const token = await generateVerificationToken(email);
|
||||||
|
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
|
||||||
|
<h2 style="margin-bottom:16px">Verify your email address</h2>
|
||||||
|
<p>Click the button below to verify your email for Echo OCR.</p>
|
||||||
|
<a href="${verifyUrl}"
|
||||||
|
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="font-size:13px;color:#666">
|
||||||
|
If you didn't create an account, you can ignore this email.
|
||||||
|
This link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail(email, "Verify your email — Echo OCR", html);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
mapCardToColumnValues,
|
mapCardToColumnValues,
|
||||||
} from "./monday";
|
} from "./monday";
|
||||||
import { sendWebhook } from "./webhook";
|
import { sendWebhook } from "./webhook";
|
||||||
|
import { getProvider } from "./integrations/registry";
|
||||||
|
import type { CardData } from "./integrations/types";
|
||||||
|
|
||||||
export type IntegrationEvent =
|
export type IntegrationEvent =
|
||||||
| "ocr_complete"
|
| "ocr_complete"
|
||||||
|
|
@ -23,52 +25,97 @@ export async function fireIntegrationEvent(
|
||||||
extra?: { oldCard?: Record<string, unknown> }
|
extra?: { oldCard?: Record<string, unknown> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const [settings, card] = await Promise.all([
|
const card = await prisma.responseCard.findUnique({
|
||||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
where: { id: cardId },
|
||||||
prisma.responseCard.findUnique({ where: { id: cardId } }),
|
});
|
||||||
]);
|
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
const cardData = card as unknown as Record<string, unknown>;
|
const cardData = card as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// --- Activity Log ---
|
|
||||||
logActivityForEvent(event, cardId, cardData, extra?.oldCard).catch(() => {});
|
logActivityForEvent(event, cardId, cardData, extra?.oldCard).catch(() => {});
|
||||||
|
|
||||||
// --- Notifications ---
|
|
||||||
createNotificationForEvent(event, cardId, cardData).catch(() => {});
|
createNotificationForEvent(event, cardId, cardData).catch(() => {});
|
||||||
|
|
||||||
if (!settings) return;
|
// --- New Integration model-based dispatch ---
|
||||||
|
if (card.organizationId) {
|
||||||
|
const integrations = await prisma.integration.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: card.organizationId,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// --- Monday.com ---
|
for (const integration of integrations) {
|
||||||
if (settings.mondayEnabled && settings.mondayApiToken && settings.mondayBoardId) {
|
const triggers = (integration.triggerEvents as string[]) || [];
|
||||||
handleMonday(event, settings, card as unknown as Record<string, unknown>, cardId).catch((err) => {
|
if (!triggers.includes(event)) continue;
|
||||||
console.error("[integrations] Monday.com error:", err);
|
|
||||||
|
const provider = getProvider(integration.provider);
|
||||||
|
if (!provider) continue;
|
||||||
|
|
||||||
|
provider
|
||||||
|
.pushCard(
|
||||||
|
cardData as unknown as CardData,
|
||||||
|
integration.config,
|
||||||
|
integration.fieldMapping
|
||||||
|
)
|
||||||
|
.then(async (result) => {
|
||||||
|
await prisma.integration.update({
|
||||||
|
where: { id: integration.id },
|
||||||
|
data: {
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
lastSyncStatus: result.success ? "success" : "error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
createNotification({
|
createNotification({
|
||||||
type: "monday_error",
|
type: "integration_error",
|
||||||
title: "Monday.com Sync Failed",
|
title: `${integration.name} Sync Failed`,
|
||||||
message: err instanceof Error ? err.message : "Unknown error",
|
message: result.message || "Unknown error",
|
||||||
cardId,
|
cardId,
|
||||||
actionUrl: `/cards/${cardId}`,
|
actionUrl: `/cards/${cardId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`[integrations] ${integration.provider} error:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy AppSettings-based dispatch (deprecated, kept for backward compat) ---
|
||||||
|
const settings = await prisma.appSettings
|
||||||
|
.findUnique({ where: { id: "singleton" } })
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
if (
|
||||||
|
settings.mondayEnabled &&
|
||||||
|
settings.mondayApiToken &&
|
||||||
|
settings.mondayBoardId
|
||||||
|
) {
|
||||||
|
handleMonday(
|
||||||
|
event,
|
||||||
|
settings,
|
||||||
|
card as unknown as Record<string, unknown>,
|
||||||
|
cardId
|
||||||
|
).catch((err) => {
|
||||||
|
console.error("[integrations] Monday.com legacy error:", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Generic Webhook ---
|
|
||||||
if (settings.webhookEnabled && settings.webhookUrl) {
|
if (settings.webhookEnabled && settings.webhookUrl) {
|
||||||
const events = (settings.webhookEvents as string[] | null) ?? [];
|
const events = (settings.webhookEvents as string[] | null) ?? [];
|
||||||
if (events.includes(event)) {
|
if (events.includes(event)) {
|
||||||
sendWebhook(settings.webhookUrl, settings.webhookSecret, event, cardData).then((result) => {
|
sendWebhook(
|
||||||
if (!result.ok) {
|
settings.webhookUrl,
|
||||||
console.error("[integrations] Webhook error:", result.error);
|
settings.webhookSecret,
|
||||||
createNotification({
|
event,
|
||||||
type: "webhook_error",
|
cardData
|
||||||
title: "Webhook Delivery Failed",
|
).catch(() => {});
|
||||||
message: `${result.error} (${settings.webhookUrl})`,
|
|
||||||
cardId,
|
|
||||||
actionUrl: `/cards/${cardId}`,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
173
src/lib/integrations/providers/airtable.ts
Normal file
173
src/lib/integrations/providers/airtable.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import type {
|
||||||
|
IntegrationProvider,
|
||||||
|
CardData,
|
||||||
|
TestResult,
|
||||||
|
PushResult,
|
||||||
|
ExternalField,
|
||||||
|
JsonValue,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const AIRTABLE_API = "https://api.airtable.com/v0";
|
||||||
|
|
||||||
|
interface AirtableConfig {
|
||||||
|
personalAccessToken: string;
|
||||||
|
baseId: string;
|
||||||
|
tableIdOrName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): AirtableConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
personalAccessToken: (c.personalAccessToken as string) || "",
|
||||||
|
baseId: (c.baseId as string) || "",
|
||||||
|
tableIdOrName: (c.tableIdOrName as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function airtableFetch(
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${AIRTABLE_API}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Airtable API ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const airtableProvider: IntegrationProvider = {
|
||||||
|
id: "airtable",
|
||||||
|
name: "Airtable",
|
||||||
|
description: "Push response cards as rows in Airtable bases",
|
||||||
|
icon: "airtable",
|
||||||
|
category: "spreadsheet",
|
||||||
|
supportsFieldMapping: true,
|
||||||
|
supportsOAuth: false,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "personalAccessToken",
|
||||||
|
label: "Personal Access Token",
|
||||||
|
type: "password",
|
||||||
|
required: true,
|
||||||
|
helpText: "Create at airtable.com/create/tokens",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "baseId",
|
||||||
|
label: "Base ID",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
placeholder: "appXXXXXXXXXXXXXX",
|
||||||
|
helpText: "Found in the Airtable API docs for your base",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tableIdOrName",
|
||||||
|
label: "Table Name or ID",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Response Cards",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(config: JsonValue): Promise<TestResult> {
|
||||||
|
try {
|
||||||
|
const { personalAccessToken, baseId, tableIdOrName } =
|
||||||
|
parseConfig(config);
|
||||||
|
if (!personalAccessToken || !baseId || !tableIdOrName) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Token, base ID, and table name are all required",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await airtableFetch(
|
||||||
|
personalAccessToken,
|
||||||
|
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connected to table (${data.records?.length ?? 0} sample records)`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Connection failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
|
||||||
|
const { personalAccessToken, baseId, tableIdOrName } =
|
||||||
|
parseConfig(config);
|
||||||
|
|
||||||
|
const data = await airtableFetch(
|
||||||
|
personalAccessToken,
|
||||||
|
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.records?.[0]?.fields) {
|
||||||
|
return Object.keys(data.records[0].fields).map((key) => ({
|
||||||
|
id: key,
|
||||||
|
name: key,
|
||||||
|
type: typeof data.records[0].fields[key],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
mapping: JsonValue
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { personalAccessToken, baseId, tableIdOrName } =
|
||||||
|
parseConfig(config);
|
||||||
|
const fieldMap = (mapping as Record<string, string>) || {};
|
||||||
|
|
||||||
|
const fields: Record<string, unknown> = {};
|
||||||
|
for (const [cardField, airtableField] of Object.entries(fieldMap)) {
|
||||||
|
if (!airtableField || cardField.startsWith("_")) continue;
|
||||||
|
const val = card[cardField];
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
fields[airtableField] = typeof val === "object" ? JSON.stringify(val) : val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fields).length === 0 && card.name) {
|
||||||
|
fields["Name"] = card.name;
|
||||||
|
if (card.email) fields["Email"] = card.email;
|
||||||
|
if (card.cellPhone) fields["Phone"] = card.cellPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await airtableFetch(
|
||||||
|
personalAccessToken,
|
||||||
|
`/${baseId}/${encodeURIComponent(tableIdOrName)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ records: [{ fields }] }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordId = data.records?.[0]?.id;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
externalId: recordId,
|
||||||
|
message: `Created record ${recordId}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Push failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
123
src/lib/integrations/providers/csv-export.ts
Normal file
123
src/lib/integrations/providers/csv-export.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import type {
|
||||||
|
IntegrationProvider,
|
||||||
|
CardData,
|
||||||
|
TestResult,
|
||||||
|
PushResult,
|
||||||
|
JsonValue,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const DEFAULT_COLUMNS = [
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"cellPhone",
|
||||||
|
"homePhone",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"gender",
|
||||||
|
"dateOfBirth",
|
||||||
|
"maritalStatus",
|
||||||
|
"visitType",
|
||||||
|
"prayerRequests",
|
||||||
|
"followUp",
|
||||||
|
"notes",
|
||||||
|
"serviceAttended",
|
||||||
|
"firstTimeGuestDate",
|
||||||
|
"salvationDate",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CsvConfig {
|
||||||
|
format: "csv" | "tsv";
|
||||||
|
columns: string[];
|
||||||
|
includeHeaders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): CsvConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
format: (c.format as "csv" | "tsv") || "csv",
|
||||||
|
columns: (c.columns as string[]) || DEFAULT_COLUMNS,
|
||||||
|
includeHeaders: c.includeHeaders !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCell(val: string, delimiter: string): string {
|
||||||
|
if (
|
||||||
|
val.includes(delimiter) ||
|
||||||
|
val.includes('"') ||
|
||||||
|
val.includes("\n")
|
||||||
|
) {
|
||||||
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardToCsvRow(
|
||||||
|
card: CardData,
|
||||||
|
columns: string[],
|
||||||
|
delimiter: string
|
||||||
|
): string {
|
||||||
|
return columns
|
||||||
|
.map((col) => {
|
||||||
|
const val = card[col];
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
if (val instanceof Date) return val.toISOString().split("T")[0];
|
||||||
|
if (typeof val === "object") return escapeCell(JSON.stringify(val), delimiter);
|
||||||
|
return escapeCell(String(val), delimiter);
|
||||||
|
})
|
||||||
|
.join(delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const csvExportProvider: IntegrationProvider = {
|
||||||
|
id: "csv_export",
|
||||||
|
name: "CSV / Excel Export",
|
||||||
|
description: "Generate CSV or TSV files from processed cards",
|
||||||
|
icon: "csv_export",
|
||||||
|
category: "export",
|
||||||
|
supportsFieldMapping: false,
|
||||||
|
supportsOAuth: false,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "format",
|
||||||
|
label: "Format",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "csv", label: "CSV (comma-separated)" },
|
||||||
|
{ value: "tsv", label: "TSV (tab-separated, Excel-friendly)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "includeHeaders",
|
||||||
|
label: "Include column headers",
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(): Promise<TestResult> {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "CSV export is always available — no external connection needed",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { format, columns } = parseConfig(config);
|
||||||
|
const delimiter = format === "tsv" ? "\t" : ",";
|
||||||
|
const row = cardToCsvRow(card, columns, delimiter);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Generated ${format.toUpperCase()} row (${row.length} chars)`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Export failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
173
src/lib/integrations/providers/google-sheets.ts
Normal file
173
src/lib/integrations/providers/google-sheets.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import type {
|
||||||
|
IntegrationProvider,
|
||||||
|
CardData,
|
||||||
|
TestResult,
|
||||||
|
PushResult,
|
||||||
|
ExternalField,
|
||||||
|
JsonValue,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
|
||||||
|
|
||||||
|
interface GSheetsConfig {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenExpiresAt?: number;
|
||||||
|
spreadsheetId: string;
|
||||||
|
sheetName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): GSheetsConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
accessToken: (c.accessToken as string) || "",
|
||||||
|
refreshToken: (c.refreshToken as string) || undefined,
|
||||||
|
tokenExpiresAt: (c.tokenExpiresAt as number) || undefined,
|
||||||
|
spreadsheetId: (c.spreadsheetId as string) || "",
|
||||||
|
sheetName: (c.sheetName as string) || "Sheet1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sheetsFetch(
|
||||||
|
accessToken: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${SHEETS_API}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Google Sheets API ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const googleSheetsProvider: IntegrationProvider = {
|
||||||
|
id: "google_sheets",
|
||||||
|
name: "Google Sheets",
|
||||||
|
description: "Append response cards as rows in Google Sheets",
|
||||||
|
icon: "google_sheets",
|
||||||
|
category: "spreadsheet",
|
||||||
|
supportsFieldMapping: true,
|
||||||
|
supportsOAuth: true,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "spreadsheetId",
|
||||||
|
label: "Spreadsheet ID",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
placeholder: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
|
||||||
|
helpText: "Found in the spreadsheet URL between /d/ and /edit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sheetName",
|
||||||
|
label: "Sheet Name",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Sheet1",
|
||||||
|
helpText: "The tab name to append rows to",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(config: JsonValue): Promise<TestResult> {
|
||||||
|
try {
|
||||||
|
const { accessToken, spreadsheetId } = parseConfig(config);
|
||||||
|
if (!accessToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Not connected — use the Connect button to authorize with Google",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
return { success: false, message: "Spreadsheet ID is required" };
|
||||||
|
}
|
||||||
|
const data = await sheetsFetch(
|
||||||
|
accessToken,
|
||||||
|
`/${spreadsheetId}?fields=properties.title,sheets.properties.title`
|
||||||
|
);
|
||||||
|
const title = data.properties?.title || "Unknown";
|
||||||
|
const sheetCount = data.sheets?.length ?? 0;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connected to "${title}" (${sheetCount} sheets)`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Connection failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
|
||||||
|
try {
|
||||||
|
const { accessToken, spreadsheetId, sheetName } = parseConfig(config);
|
||||||
|
const range = `${sheetName || "Sheet1"}!1:1`;
|
||||||
|
const data = await sheetsFetch(
|
||||||
|
accessToken,
|
||||||
|
`/${spreadsheetId}/values/${encodeURIComponent(range)}`
|
||||||
|
);
|
||||||
|
const headers = data.values?.[0] || [];
|
||||||
|
return headers.map((h: string, i: number) => ({
|
||||||
|
id: String(i),
|
||||||
|
name: h,
|
||||||
|
type: "text",
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
mapping: JsonValue
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { accessToken, spreadsheetId, sheetName } = parseConfig(config);
|
||||||
|
if (!accessToken || !spreadsheetId) {
|
||||||
|
return { success: false, message: "Google Sheets not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMap = (mapping as Record<string, string>) || {};
|
||||||
|
const headerRange = `${sheetName || "Sheet1"}!1:1`;
|
||||||
|
const headerData = await sheetsFetch(
|
||||||
|
accessToken,
|
||||||
|
`/${spreadsheetId}/values/${encodeURIComponent(headerRange)}`
|
||||||
|
);
|
||||||
|
const headers: string[] = headerData.values?.[0] || [];
|
||||||
|
|
||||||
|
const row: string[] = headers.map((header) => {
|
||||||
|
const cardField = Object.entries(fieldMap).find(
|
||||||
|
([, ext]) => ext === header
|
||||||
|
)?.[0];
|
||||||
|
if (!cardField) return "";
|
||||||
|
const val = card[cardField];
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
return typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
const range = `${sheetName || "Sheet1"}!A:A`;
|
||||||
|
await sheetsFetch(
|
||||||
|
accessToken,
|
||||||
|
`/${spreadsheetId}/values/${encodeURIComponent(range)}:append?valueInputOption=USER_ENTERED`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ values: [row] }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: "Row appended to sheet" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Push failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
99
src/lib/integrations/providers/monday.ts
Normal file
99
src/lib/integrations/providers/monday.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { IntegrationProvider, CardData, TestResult, PushResult, ExternalField, JsonValue } from "../types";
|
||||||
|
import {
|
||||||
|
createItem,
|
||||||
|
updateItem,
|
||||||
|
fetchBoardColumns,
|
||||||
|
mapCardToColumnValues,
|
||||||
|
} from "@/lib/monday";
|
||||||
|
|
||||||
|
interface MondayConfig {
|
||||||
|
apiToken: string;
|
||||||
|
boardId: string;
|
||||||
|
columnMap?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): MondayConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
apiToken: (c.apiToken as string) || "",
|
||||||
|
boardId: (c.boardId as string) || "",
|
||||||
|
columnMap: (c.columnMap as Record<string, unknown>) || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mondayProvider: IntegrationProvider = {
|
||||||
|
id: "monday",
|
||||||
|
name: "Monday.com",
|
||||||
|
description: "Push response cards to Monday.com boards as items",
|
||||||
|
icon: "monday",
|
||||||
|
category: "project_mgmt",
|
||||||
|
supportsFieldMapping: true,
|
||||||
|
supportsOAuth: false,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "apiToken",
|
||||||
|
label: "API Token",
|
||||||
|
type: "password",
|
||||||
|
required: true,
|
||||||
|
helpText: "Found in Monday.com → Admin → API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "boardId",
|
||||||
|
label: "Board ID",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
helpText: "The numeric board ID from the board URL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(config: JsonValue): Promise<TestResult> {
|
||||||
|
try {
|
||||||
|
const { apiToken, boardId } = parseConfig(config);
|
||||||
|
if (!apiToken || !boardId) {
|
||||||
|
return { success: false, message: "API token and board ID are required" };
|
||||||
|
}
|
||||||
|
const columns = await fetchBoardColumns(apiToken, boardId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connected — found ${columns.length} columns`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Connection failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
|
||||||
|
const { apiToken, boardId } = parseConfig(config);
|
||||||
|
const columns = await fetchBoardColumns(apiToken, boardId);
|
||||||
|
return columns.map((col) => ({
|
||||||
|
id: col.id,
|
||||||
|
name: col.title,
|
||||||
|
type: col.type,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
mapping: JsonValue
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { apiToken, boardId, columnMap } = parseConfig(config);
|
||||||
|
const effectiveMap = (mapping as Record<string, unknown>) || columnMap || {};
|
||||||
|
const cardData = card as unknown as Record<string, unknown>;
|
||||||
|
const columnValues = mapCardToColumnValues(cardData, effectiveMap);
|
||||||
|
const itemName = card.name || "Unnamed Card";
|
||||||
|
|
||||||
|
const itemId = await createItem(apiToken, boardId, itemName, columnValues);
|
||||||
|
return { success: true, externalId: itemId };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Push failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
316
src/lib/integrations/providers/planning-center.ts
Normal file
316
src/lib/integrations/providers/planning-center.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import type {
|
||||||
|
IntegrationProvider,
|
||||||
|
CardData,
|
||||||
|
TestResult,
|
||||||
|
PushResult,
|
||||||
|
ExternalField,
|
||||||
|
JsonValue,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const PCO_API_BASE = "https://api.planningcenteronline.com/people/v2";
|
||||||
|
|
||||||
|
interface PcoConfig {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenExpiresAt?: number;
|
||||||
|
defaultListId?: string;
|
||||||
|
defaultWorkflowId?: string;
|
||||||
|
matchStrategy?: "email_first" | "name_first" | "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): PcoConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
accessToken: (c.accessToken as string) || "",
|
||||||
|
refreshToken: (c.refreshToken as string) || undefined,
|
||||||
|
tokenExpiresAt: (c.tokenExpiresAt as number) || undefined,
|
||||||
|
defaultListId: (c.defaultListId as string) || undefined,
|
||||||
|
defaultWorkflowId: (c.defaultWorkflowId as string) || undefined,
|
||||||
|
matchStrategy:
|
||||||
|
(c.matchStrategy as PcoConfig["matchStrategy"]) || "email_first",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pcoFetch(
|
||||||
|
accessToken: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${PCO_API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`PCO API ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPersonByEmail(
|
||||||
|
accessToken: string,
|
||||||
|
email: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const data = await pcoFetch(
|
||||||
|
accessToken,
|
||||||
|
`/emails?where[address]=${encodeURIComponent(email)}&include=person`
|
||||||
|
);
|
||||||
|
const included = data.included;
|
||||||
|
if (included && included.length > 0) {
|
||||||
|
return included[0].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPersonByName(
|
||||||
|
accessToken: string,
|
||||||
|
firstName: string,
|
||||||
|
lastName: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const data = await pcoFetch(
|
||||||
|
accessToken,
|
||||||
|
`/people?where[first_name]=${encodeURIComponent(firstName)}&where[last_name]=${encodeURIComponent(lastName)}`
|
||||||
|
);
|
||||||
|
if (data.data && data.data.length > 0) {
|
||||||
|
return data.data[0].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitName(name: string): { firstName: string; lastName: string } {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0], lastName: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstName: parts[0],
|
||||||
|
lastName: parts.slice(1).join(" "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const planningCenterProvider: IntegrationProvider = {
|
||||||
|
id: "planning_center",
|
||||||
|
name: "Planning Center",
|
||||||
|
description:
|
||||||
|
"Sync people and response cards to Planning Center Online",
|
||||||
|
icon: "planning_center",
|
||||||
|
category: "chms",
|
||||||
|
supportsFieldMapping: true,
|
||||||
|
supportsOAuth: true,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "defaultListId",
|
||||||
|
label: "Default List ID (optional)",
|
||||||
|
type: "text",
|
||||||
|
helpText: "Add matched/created people to this PCO List",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "defaultWorkflowId",
|
||||||
|
label: "Default Workflow ID (optional)",
|
||||||
|
type: "text",
|
||||||
|
helpText: "Trigger this workflow for new people",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "matchStrategy",
|
||||||
|
label: "Match Strategy",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "email_first", label: "Email first, then name" },
|
||||||
|
{ value: "name_first", label: "Name first, then email" },
|
||||||
|
{ value: "manual", label: "Manual review only" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(config: JsonValue): Promise<TestResult> {
|
||||||
|
try {
|
||||||
|
const { accessToken } = parseConfig(config);
|
||||||
|
if (!accessToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Not connected — use the Connect button to authorize with Planning Center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await pcoFetch(accessToken, "/people?per_page=1");
|
||||||
|
const total = data.meta?.total_count ?? "?";
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connected — ${total} people in PCO`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Connection failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
|
||||||
|
const { accessToken } = parseConfig(config);
|
||||||
|
const builtIn: ExternalField[] = [
|
||||||
|
{ id: "first_name", name: "First Name", type: "text" },
|
||||||
|
{ id: "last_name", name: "Last Name", type: "text" },
|
||||||
|
{ id: "gender", name: "Gender", type: "text" },
|
||||||
|
{ id: "birthdate", name: "Birthdate", type: "date" },
|
||||||
|
{ id: "membership", name: "Membership", type: "text" },
|
||||||
|
{ id: "status", name: "Status", type: "text" },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await pcoFetch(accessToken, "/field_definitions");
|
||||||
|
const custom = (data.data || []).map(
|
||||||
|
(fd: { id: string; attributes: { name: string; data_type: string } }) => ({
|
||||||
|
id: `custom_${fd.id}`,
|
||||||
|
name: fd.attributes.name,
|
||||||
|
type: fd.attributes.data_type,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return [...builtIn, ...custom];
|
||||||
|
} catch {
|
||||||
|
return builtIn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { accessToken, matchStrategy, defaultListId } =
|
||||||
|
parseConfig(config);
|
||||||
|
if (!accessToken) {
|
||||||
|
return { success: false, message: "Not connected to Planning Center" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let personId: string | null = null;
|
||||||
|
const { firstName, lastName } = card.name
|
||||||
|
? splitName(card.name)
|
||||||
|
: { firstName: "", lastName: "" };
|
||||||
|
|
||||||
|
if (matchStrategy === "email_first" || !matchStrategy) {
|
||||||
|
if (card.email) {
|
||||||
|
personId = await findPersonByEmail(accessToken, card.email);
|
||||||
|
}
|
||||||
|
if (!personId && firstName) {
|
||||||
|
personId = await findPersonByName(accessToken, firstName, lastName);
|
||||||
|
}
|
||||||
|
} else if (matchStrategy === "name_first") {
|
||||||
|
if (firstName) {
|
||||||
|
personId = await findPersonByName(accessToken, firstName, lastName);
|
||||||
|
}
|
||||||
|
if (!personId && card.email) {
|
||||||
|
personId = await findPersonByEmail(accessToken, card.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const personAttrs: Record<string, unknown> = {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
};
|
||||||
|
if (card.gender) personAttrs.gender = card.gender;
|
||||||
|
|
||||||
|
if (personId) {
|
||||||
|
await pcoFetch(accessToken, `/people/${personId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "Person",
|
||||||
|
id: personId,
|
||||||
|
attributes: personAttrs,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const createRes = await pcoFetch(accessToken, "/people", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "Person",
|
||||||
|
attributes: personAttrs,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
personId = createRes.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.email && personId) {
|
||||||
|
try {
|
||||||
|
await pcoFetch(accessToken, `/people/${personId}/emails`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "Email",
|
||||||
|
attributes: {
|
||||||
|
address: card.email,
|
||||||
|
location: "Home",
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// email may already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.cellPhone && personId) {
|
||||||
|
try {
|
||||||
|
await pcoFetch(
|
||||||
|
accessToken,
|
||||||
|
`/people/${personId}/phone_numbers`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "PhoneNumber",
|
||||||
|
attributes: {
|
||||||
|
number: card.cellPhone,
|
||||||
|
location: "Mobile",
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// phone may already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultListId && personId) {
|
||||||
|
try {
|
||||||
|
await pcoFetch(
|
||||||
|
accessToken,
|
||||||
|
`/lists/${defaultListId}/people`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: { type: "Person", id: personId },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// may already be on list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
externalId: personId ?? undefined,
|
||||||
|
message: personId
|
||||||
|
? `Synced to PCO person ${personId}`
|
||||||
|
: "Created in PCO",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Push failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
86
src/lib/integrations/providers/webhook.ts
Normal file
86
src/lib/integrations/providers/webhook.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { IntegrationProvider, CardData, TestResult, PushResult, JsonValue } from "../types";
|
||||||
|
import { sendWebhook } from "@/lib/webhook";
|
||||||
|
|
||||||
|
interface WebhookConfig {
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(config: JsonValue): WebhookConfig {
|
||||||
|
const c = config as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
url: (c.url as string) || "",
|
||||||
|
secret: (c.secret as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webhookProvider: IntegrationProvider = {
|
||||||
|
id: "webhook",
|
||||||
|
name: "Webhook",
|
||||||
|
description: "Send card data to any URL via HTTP POST",
|
||||||
|
icon: "webhook",
|
||||||
|
category: "webhook",
|
||||||
|
supportsFieldMapping: false,
|
||||||
|
supportsOAuth: false,
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "url",
|
||||||
|
label: "Webhook URL",
|
||||||
|
type: "url",
|
||||||
|
required: true,
|
||||||
|
placeholder: "https://example.com/webhook",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "secret",
|
||||||
|
label: "Signing Secret (optional)",
|
||||||
|
type: "password",
|
||||||
|
helpText: "Used to sign payloads with HMAC-SHA256",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async testConnection(config: JsonValue): Promise<TestResult> {
|
||||||
|
try {
|
||||||
|
const { url, secret } = parseConfig(config);
|
||||||
|
if (!url) {
|
||||||
|
return { success: false, message: "Webhook URL is required" };
|
||||||
|
}
|
||||||
|
const result = await sendWebhook(url, secret, "test", {
|
||||||
|
test: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
return { success: true, message: `Webhook responded with ${result.status}` };
|
||||||
|
}
|
||||||
|
return { success: false, message: result.error || "Webhook failed" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Connection failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
): Promise<PushResult> {
|
||||||
|
try {
|
||||||
|
const { url, secret } = parseConfig(config);
|
||||||
|
const result = await sendWebhook(
|
||||||
|
url,
|
||||||
|
secret,
|
||||||
|
"card_push",
|
||||||
|
card as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
if (result.ok) {
|
||||||
|
return { success: true, message: `Delivered (HTTP ${result.status})` };
|
||||||
|
}
|
||||||
|
return { success: false, message: result.error || "Delivery failed" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : "Push failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
34
src/lib/integrations/registry.ts
Normal file
34
src/lib/integrations/registry.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { IntegrationProvider } from "./types";
|
||||||
|
import { mondayProvider } from "./providers/monday";
|
||||||
|
import { webhookProvider } from "./providers/webhook";
|
||||||
|
import { planningCenterProvider } from "./providers/planning-center";
|
||||||
|
import { airtableProvider } from "./providers/airtable";
|
||||||
|
import { googleSheetsProvider } from "./providers/google-sheets";
|
||||||
|
import { csvExportProvider } from "./providers/csv-export";
|
||||||
|
|
||||||
|
const providers: Map<string, IntegrationProvider> = new Map();
|
||||||
|
|
||||||
|
function register(provider: IntegrationProvider) {
|
||||||
|
providers.set(provider.id, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(planningCenterProvider);
|
||||||
|
register(mondayProvider);
|
||||||
|
register(airtableProvider);
|
||||||
|
register(googleSheetsProvider);
|
||||||
|
register(webhookProvider);
|
||||||
|
register(csvExportProvider);
|
||||||
|
|
||||||
|
export function getProvider(id: string): IntegrationProvider | undefined {
|
||||||
|
return providers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllProviders(): IntegrationProvider[] {
|
||||||
|
return Array.from(providers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProvidersByCategory(
|
||||||
|
category: string
|
||||||
|
): IntegrationProvider[] {
|
||||||
|
return getAllProviders().filter((p) => p.category === category);
|
||||||
|
}
|
||||||
82
src/lib/integrations/types.ts
Normal file
82
src/lib/integrations/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type JsonValue = any;
|
||||||
|
|
||||||
|
export interface ConfigField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "password" | "number" | "boolean" | "select" | "url";
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
helpText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
success: boolean;
|
||||||
|
externalId?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardData {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
cellPhone?: string | null;
|
||||||
|
homePhone?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
aptNumber?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
gender?: string | null;
|
||||||
|
dateOfBirth?: string | null;
|
||||||
|
maritalStatus?: string | null;
|
||||||
|
visitType?: string | null;
|
||||||
|
prayerRequests?: string | null;
|
||||||
|
followUp?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
serviceAttended?: string | null;
|
||||||
|
firstTimeGuestDate?: Date | null;
|
||||||
|
salvationDate?: Date | null;
|
||||||
|
messageTopics?: JsonValue;
|
||||||
|
nextStep?: JsonValue;
|
||||||
|
howHeard?: JsonValue;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderCategory =
|
||||||
|
| "chms"
|
||||||
|
| "project_mgmt"
|
||||||
|
| "spreadsheet"
|
||||||
|
| "export"
|
||||||
|
| "webhook";
|
||||||
|
|
||||||
|
export interface IntegrationProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
category: ProviderCategory;
|
||||||
|
configFields: ConfigField[];
|
||||||
|
supportsFieldMapping: boolean;
|
||||||
|
supportsOAuth: boolean;
|
||||||
|
|
||||||
|
testConnection(config: JsonValue): Promise<TestResult>;
|
||||||
|
getExternalFields?(config: JsonValue): Promise<ExternalField[]>;
|
||||||
|
pushCard(
|
||||||
|
card: CardData,
|
||||||
|
config: JsonValue,
|
||||||
|
mapping: JsonValue
|
||||||
|
): Promise<PushResult>;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { auth } from "@/auth";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
const publicPaths = [
|
const publicPaths = [
|
||||||
"/login",
|
"/login",
|
||||||
|
|
@ -8,6 +8,13 @@ const publicPaths = [
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/setup",
|
"/api/setup",
|
||||||
|
"/api/onboarding",
|
||||||
|
];
|
||||||
|
|
||||||
|
const onboardingExemptPaths = [
|
||||||
|
"/onboarding",
|
||||||
|
"/api/onboarding",
|
||||||
|
"/api/auth",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isPublic(pathname: string) {
|
function isPublic(pathname: string) {
|
||||||
|
|
@ -16,7 +23,13 @@ function isPublic(pathname: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default auth((req) => {
|
function isOnboardingExempt(pathname: string) {
|
||||||
|
return onboardingExemptPaths.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(p + "/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -31,14 +44,24 @@ export default auth((req) => {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.auth) {
|
const token = await getToken({ req, secret: process.env.AUTH_SECRET });
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
const loginUrl = new URL("/login", req.url);
|
const loginUrl = new URL("/login", req.url);
|
||||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
token.onboardingComplete === false &&
|
||||||
|
token.orgRole === "owner" &&
|
||||||
|
!isOnboardingExempt(pathname)
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(new URL("/onboarding", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
});
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
|
|
||||||
4
src/types/next-auth.d.ts
vendored
4
src/types/next-auth.d.ts
vendored
|
|
@ -12,6 +12,8 @@ declare module "next-auth" {
|
||||||
orgName?: string;
|
orgName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
isEmailVerified?: boolean;
|
||||||
|
onboardingComplete?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,5 +31,7 @@ declare module "next-auth/jwt" {
|
||||||
orgRole?: string;
|
orgRole?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
isEmailVerified?: boolean;
|
||||||
|
onboardingComplete?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue