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 */}
-