diff --git a/apps/web/src/actions/accounts.ts b/apps/web/src/actions/accounts.ts new file mode 100644 index 0000000..c1f7fe7 --- /dev/null +++ b/apps/web/src/actions/accounts.ts @@ -0,0 +1,114 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; +import { pgNotifyBot } from "@/lib/notify"; +import { checkRateLimit } from "@/lib/rate-limit"; + +async function rateLimit(key: string) { + const h = await headers(); + const ip = + h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + h.get("x-real-ip") ?? + "unknown"; + const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 }); + if (r.limited) throw new Error("Too many requests"); +} + +const pairSchema = z.object({ + label: z + .string() + .trim() + .min(1, "Label is required") + .max(60, "Label too long (max 60)"), +}); + +export type PairResult = + | { ok: true; accountId: string } + | { ok: false; error: string }; + +export async function pairAccountAction( + _prev: unknown, + formData: FormData, +): Promise { + await rateLimit("pair"); + const parsed = pairSchema.safeParse({ label: formData.get("label") }); + if (!parsed.success) { + return { + ok: false, + error: parsed.error.issues[0]?.message ?? "Invalid label", + }; + } + const op = await getSeededOperator(); + const existing = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)), + }); + if (existing && existing.status === "connected") { + return { + ok: false, + error: `"${parsed.data.label}" is already connected. Unpair first.`, + }; + } + + let accountId = existing?.id; + if (!accountId) { + const [created] = await db + .insert(whatsappAccounts) + .values({ + operatorId: op.id, + label: parsed.data.label, + status: "pending", + }) + .returning({ id: whatsappAccounts.id }); + accountId = created!.id; + } + + await pgNotifyBot({ type: "account.start_pairing", accountId }); + revalidatePath("/accounts"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/accounts/${accountId}/pairing` as any); +} + +export async function unpairAccountAction(formData: FormData): Promise { + await rateLimit("unpair"); + const accountId = formData.get("accountId"); + if (typeof accountId !== "string") return; + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + await pgNotifyBot({ type: "account.unpair", accountId }); + // Optimistic UI — bot will overwrite status via the same column + await db + .update(whatsappAccounts) + .set({ status: "logged_out", phoneNumber: null }) + .where(eq(whatsappAccounts.id, accountId)); + revalidatePath("/accounts"); + revalidatePath(`/accounts/${accountId}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect("/accounts" as any); +} + +export async function syncGroupsAction(formData: FormData): Promise { + await rateLimit("sync"); + const accountId = formData.get("accountId"); + if (typeof accountId !== "string") return; + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + await pgNotifyBot({ type: "account.sync_groups", accountId }); + revalidatePath(`/accounts/${accountId}`); + revalidatePath(`/accounts/${accountId}/groups`); +} diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx index f2d29dc..256ee4d 100644 --- a/apps/web/src/app/accounts/[id]/page.tsx +++ b/apps/web/src/app/accounts/[id]/page.tsx @@ -30,6 +30,7 @@ import { import { AccountStatusBadge } from "@/components/account-status-badge"; import { getSeededOperator } from "@/lib/operator"; import { getAccount } from "@/lib/queries"; +import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts"; interface AccountDetailPageProps { params: Promise<{ id: string }>; @@ -104,8 +105,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro

- {/* No-op placeholder — wired in Task 17 */} -
{ "use server"; }}> + + + + {/* Header */} +
+

{account.label}

+

+ Waiting for WhatsApp pairing… +

+
+ + {/* Live QR card */} + + + Scan QR code + + A QR code will appear below. Scan it with WhatsApp on your phone to link this account. + + + + + + + + ); +} diff --git a/apps/web/src/app/accounts/new/page.tsx b/apps/web/src/app/accounts/new/page.tsx new file mode 100644 index 0000000..9ff7e45 --- /dev/null +++ b/apps/web/src/app/accounts/new/page.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { ArrowLeftIcon, SmartphoneIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { PairForm } from "@/components/pair-form"; + +export default function NewAccountPage() { + return ( +
+ {/* Back */} + + + {/* Header */} +
+
+ +
+
+

Pair New Account

+

+ Link a WhatsApp number to start scheduling reminders. +

+
+
+ + {/* Form card */} + + + Account details + + Give this account a short label so you can identify it later. You can pair + multiple numbers — one per account. + + + + + + +
+ ); +} diff --git a/apps/web/src/components/pair-form.tsx b/apps/web/src/components/pair-form.tsx new file mode 100644 index 0000000..80787be --- /dev/null +++ b/apps/web/src/components/pair-form.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useActionState } from "react"; +import Link from "next/link"; +import { ArrowRightIcon, Loader2Icon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { pairAccountAction, type PairResult } from "@/actions/accounts"; + +const initialState: PairResult = { ok: true, accountId: "" }; + +export function PairForm() { + const [state, action, isPending] = useActionState(pairAccountAction, initialState); + + return ( + +
+ + + {state.ok === false && ( + + )} +

+ A short name to identify this WhatsApp account. You can have multiple accounts. +

+
+ +
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx new file mode 100644 index 0000000..00a75bf --- /dev/null +++ b/apps/web/src/components/pair-live.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { CheckCircle2Icon, XCircleIcon, ScanLineIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useEvents } from "@/hooks/use-events"; + +type PairingState = + | { phase: "waiting" } + | { phase: "qr"; qrPng: string } + | { phase: "connected"; phoneNumber: string } + | { phase: "timeout" }; + +interface PairLiveProps { + accountId: string; + label: string; +} + +/** SVG countdown ring. radius=54 so circumference ≈ 339.3 */ +function CountdownRing({ seconds, total }: { seconds: number; total: number }) { + const r = 54; + const circ = 2 * Math.PI * r; + const progress = seconds / total; + const dash = circ * progress; + + return ( + + {/* Track */} + + {/* Remaining */} + + + ); +} + +const COUNTDOWN_TOTAL = 30; + +export function PairLive({ accountId, label }: PairLiveProps) { + const router = useRouter(); + const [pairingState, setPairingState] = useState({ phase: "waiting" }); + const [countdown, setCountdown] = useState(COUNTDOWN_TOTAL); + const timerRef = useRef | null>(null); + + // Reset and start countdown when QR arrives + const startCountdown = () => { + if (timerRef.current) clearInterval(timerRef.current); + setCountdown(COUNTDOWN_TOTAL); + timerRef.current = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return c - 1; + }); + }, 1000); + }; + + useEvents({ + "session.qr": (data) => { + if (data.accountId !== accountId) return; + setPairingState({ phase: "qr", qrPng: data.qrPng }); + startCountdown(); + }, + "session.connected": (data) => { + if (data.accountId !== accountId) return; + if (timerRef.current) clearInterval(timerRef.current); + setPairingState({ + phase: "connected", + phoneNumber: data.phoneNumber ?? "", + }); + }, + "session.timeout": (data) => { + if (data.accountId !== accountId) return; + if (timerRef.current) clearInterval(timerRef.current); + setPairingState({ phase: "timeout" }); + }, + }); + + // Auto-redirect on connected + useEffect(() => { + if (pairingState.phase !== "connected") return; + const t = setTimeout(() => { + router.push(`/accounts/${accountId}` as never); + }, 3000); + return () => clearTimeout(t); + }, [pairingState.phase, accountId, router]); + + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + return ( +
+ {/* Label chip */} +
+ + {label} +
+ + {/* State display */} + {pairingState.phase === "waiting" && ( +
+ +

Generating QR…

+
+ )} + + {pairingState.phase === "qr" && ( +
+ {/* QR + ring overlay */} +
+ {/* Countdown ring positioned around the QR */} +
+
+ +
+ + {countdown}s + +
+
+
+ + {/* QR image */} + WhatsApp QR code +
+ +
+

Scan with WhatsApp → Linked Devices

+

+ Open WhatsApp → tap ⋮ → Linked Devices → Link a device +

+
+
+ )} + + {pairingState.phase === "connected" && ( +
+
+ +
+
+

Account connected!

+ {pairingState.phoneNumber && ( +

+ Connected as{" "} + + +{pairingState.phoneNumber.replace(/^\+/, "")} + +

+ )} +

Redirecting in 3 seconds…

+
+
+ )} + + {pairingState.phase === "timeout" && ( +
+
+ +
+
+

Pairing timed out

+

+ The QR code expired before a device was linked. +

+
+ +
+ )} +
+ ); +}