feat(web): pair / unpair / sync server actions + live QR page

This commit is contained in:
yiekheng 2026-05-09 23:42:16 +08:00
parent de21edd905
commit 68b46f8d71
6 changed files with 519 additions and 4 deletions

View File

@ -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<PairResult> {
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<void> {
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<void> {
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`);
}

View File

@ -30,6 +30,7 @@ import {
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -104,8 +105,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</p> </p>
</div> </div>
</div> </div>
{/* No-op placeholder — wired in Task 17 */} <form action={syncGroupsAction}>
<form action={async () => { "use server"; }}> <input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="outline" size="sm"> <Button type="submit" variant="outline" size="sm">
<RefreshCwIcon /> <RefreshCwIcon />
Sync Sync
@ -144,8 +145,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter showCloseButton> <DialogFooter showCloseButton>
{/* No-op placeholder — wired in Task 17 */} <form action={unpairAccountAction}>
<form action={async () => { "use server"; }}> <input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm"> <Button type="submit" variant="destructive" size="sm">
<Trash2Icon /> <Trash2Icon />
Yes, unpair Yes, unpair

View File

@ -0,0 +1,56 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { PairLive } from "@/components/pair-live";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
interface PairingPageProps {
params: Promise<{ id: string }>;
}
export default async function PairingPage({ params }: PairingPageProps) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) {
notFound();
}
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-lg mx-auto space-y-6">
{/* Back */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
<p className="text-sm text-muted-foreground">
Waiting for WhatsApp pairing
</p>
</div>
{/* Live QR card */}
<Card>
<CardHeader>
<CardTitle>Scan QR code</CardTitle>
<CardDescription>
A QR code will appear below. Scan it with WhatsApp on your phone to link this account.
</CardDescription>
</CardHeader>
<CardContent>
<PairLive accountId={account.id} label={account.label} />
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-lg mx-auto space-y-6">
{/* Back */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<SmartphoneIcon className="size-5 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Pair New Account</h1>
<p className="text-sm text-muted-foreground">
Link a WhatsApp number to start scheduling reminders.
</p>
</div>
</div>
{/* Form card */}
<Card>
<CardHeader>
<CardTitle>Account details</CardTitle>
<CardDescription>
Give this account a short label so you can identify it later. You can pair
multiple numbers one per account.
</CardDescription>
</CardHeader>
<CardContent>
<PairForm />
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<form action={action} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="label" className="text-sm font-medium">
Account label
</Label>
<Input
id="label"
name="label"
type="text"
placeholder="e.g. Personal, Work, Support"
maxLength={60}
required
aria-invalid={state.ok === false ? true : undefined}
aria-describedby={state.ok === false ? "label-error" : undefined}
className="h-9"
autoFocus
/>
{state.ok === false && (
<p
id="label-error"
role="alert"
className="flex items-center gap-1.5 text-xs text-destructive"
>
<span className="inline-block size-1 rounded-full bg-destructive shrink-0" />
{state.error}
</p>
)}
<p className="text-xs text-muted-foreground">
A short name to identify this WhatsApp account. You can have multiple accounts.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<Button
type="submit"
disabled={isPending}
className="gap-2"
size="default"
>
{isPending ? (
<>
<Loader2Icon className="size-3.5 animate-spin" />
Starting
</>
) : (
<>
Start Pairing
<ArrowRightIcon className="size-3.5" />
</>
)}
</Button>
<Button asChild variant="ghost" size="default">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>Cancel</Link>
</Button>
</div>
</form>
);
}

View File

@ -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 (
<svg
className="absolute inset-0 -rotate-90"
width="128"
height="128"
viewBox="0 0 128 128"
aria-hidden
>
{/* Track */}
<circle
cx="64"
cy="64"
r={r}
fill="none"
strokeWidth="3"
className="stroke-muted"
/>
{/* Remaining */}
<circle
cx="64"
cy="64"
r={r}
fill="none"
strokeWidth="3"
strokeDasharray={`${dash} ${circ}`}
strokeLinecap="round"
className="stroke-foreground transition-[stroke-dasharray] duration-1000 ease-linear"
/>
</svg>
);
}
const COUNTDOWN_TOTAL = 30;
export function PairLive({ accountId, label }: PairLiveProps) {
const router = useRouter();
const [pairingState, setPairingState] = useState<PairingState>({ phase: "waiting" });
const [countdown, setCountdown] = useState(COUNTDOWN_TOTAL);
const timerRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex flex-col items-center gap-6 py-4">
{/* Label chip */}
<div className="flex items-center gap-1.5 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-medium text-muted-foreground">
<ScanLineIcon className="size-3" />
{label}
</div>
{/* State display */}
{pairingState.phase === "waiting" && (
<div className="flex flex-col items-center gap-3">
<Skeleton className="size-64 rounded-lg" />
<p className="text-sm text-muted-foreground">Generating QR</p>
</div>
)}
{pairingState.phase === "qr" && (
<div className="flex flex-col items-center gap-4">
{/* QR + ring overlay */}
<div className="relative">
{/* Countdown ring positioned around the QR */}
<div className="absolute -inset-5 flex items-center justify-center pointer-events-none">
<div className="relative size-32">
<CountdownRing seconds={countdown} total={COUNTDOWN_TOTAL} />
<div className="absolute inset-0 flex items-center justify-center">
<span
className={`font-mono text-xs tabular-nums font-medium transition-colors ${
countdown <= 10 ? "text-destructive" : "text-muted-foreground"
}`}
>
{countdown}s
</span>
</div>
</div>
</div>
{/* QR image */}
<img
src={`data:image/png;base64,${pairingState.qrPng}`}
alt="WhatsApp QR code"
width={256}
height={256}
className="rounded-lg ring-1 ring-foreground/10"
/>
</div>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-sm font-medium">Scan with WhatsApp Linked Devices</p>
<p className="text-xs text-muted-foreground">
Open WhatsApp tap Linked Devices Link a device
</p>
</div>
</div>
)}
{pairingState.phase === "connected" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2Icon className="size-8 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Account connected!</p>
{pairingState.phoneNumber && (
<p className="text-sm text-muted-foreground">
Connected as{" "}
<span className="font-mono font-medium text-foreground">
+{pairingState.phoneNumber.replace(/^\+/, "")}
</span>
</p>
)}
<p className="text-xs text-muted-foreground">Redirecting in 3 seconds</p>
</div>
</div>
)}
{pairingState.phase === "timeout" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircleIcon className="size-8 text-destructive" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Pairing timed out</p>
<p className="text-xs text-muted-foreground">
The QR code expired before a device was linked.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>Try again</Link>
</Button>
</div>
)}
</div>
);
}