feat(web): pair / unpair / sync server actions + live QR page
This commit is contained in:
parent
de21edd905
commit
68b46f8d71
114
apps/web/src/actions/accounts.ts
Normal file
114
apps/web/src/actions/accounts.ts
Normal 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`);
|
||||
}
|
||||
@ -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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* No-op placeholder — wired in Task 17 */}
|
||||
<form action={async () => { "use server"; }}>
|
||||
<form action={syncGroupsAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<RefreshCwIcon />
|
||||
Sync
|
||||
@ -144,8 +145,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
{/* No-op placeholder — wired in Task 17 */}
|
||||
<form action={async () => { "use server"; }}>
|
||||
<form action={unpairAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, unpair
|
||||
|
||||
56
apps/web/src/app/accounts/[id]/pairing/page.tsx
Normal file
56
apps/web/src/app/accounts/[id]/pairing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/app/accounts/new/page.tsx
Normal file
53
apps/web/src/app/accounts/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/pair-form.tsx
Normal file
76
apps/web/src/components/pair-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
apps/web/src/components/pair-live.tsx
Normal file
215
apps/web/src/components/pair-live.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user