yiekheng 670eaf493c feat(web): swipeable account rows, editable label, disabled-account guard
Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
  the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
  new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
  the override (default 88 stays for single-button shelves).

Accounts list (desktop): unchanged grid of clickable cards.

Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
  dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
  via toLocaleString instead of toLocaleDateString.

Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
  clickable. They render as a non-link with aria-disabled, dimmed
  to opacity-50 with cursor-not-allowed and a "Pair this account
  before scheduling a reminder from it" tooltip. The bot has no
  live session for those accounts, so this prevents broken submits.

renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.

Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
  Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
  accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
  button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
  Link + aria-disabled, opacity/cursor styles, "Not connected"
  copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:10 +08:00

219 lines
7.7 KiB
TypeScript

"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, whatsappGroups } 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 addAccountSchema = z.object({
label: z
.string()
.trim()
.min(1, "Label is required")
.max(60, "Label too long (max 60)"),
});
export type AddAccountResult =
| { ok: true; accountId: string }
| { ok: false; error: string };
/**
* Step 1 of the lifecycle: create an account row with status='unpaired'.
* No QR scan yet. Caller redirects to /accounts/[id] where the operator
* sees the account detail with a "Pair Now" button.
*/
export async function addAccountAction(
_prev: unknown,
formData: FormData,
): Promise<AddAccountResult> {
await rateLimit("add-account");
const parsed = addAccountSchema.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) {
return {
ok: false,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
const [created] = await db
.insert(whatsappAccounts)
.values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" })
.returning({ id: whatsappAccounts.id });
revalidatePath("/accounts");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${created!.id}` as any);
}
const renameAccountSchema = z.object({
accountId: z.string().uuid(),
label: z
.string()
.trim()
.min(1, "Label is required")
.max(60, "Label too long (max 60)"),
});
export type RenameAccountResult =
| { ok: true }
| { ok: false; error: string };
/**
* Edit the operator-facing label for an existing account. The label is
* what shows up in lists, the page header, and run history; it has no
* effect on the WhatsApp side.
*/
export async function renameAccountAction(input: {
accountId: string;
label: string;
}): Promise<RenameAccountResult> {
await rateLimit("rename-account");
const parsed = renameAccountSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, parsed.data.accountId), eq(a.operatorId, op.id)),
});
if (!account) return { ok: false, error: "Account not found" };
// Reject duplicate labels for the same operator.
const dupe = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and, ne }) =>
and(
eq(a.operatorId, op.id),
eq(a.label, parsed.data.label),
ne(a.id, parsed.data.accountId),
),
});
if (dupe) {
return {
ok: false,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
await db
.update(whatsappAccounts)
.set({ label: parsed.data.label })
.where(eq(whatsappAccounts.id, parsed.data.accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${parsed.data.accountId}`);
return { ok: true };
}
/**
* Trigger pair / re-pair for an existing account. Transitions the row to
* status='pending' and asks the bot to open a Baileys session. Operator
* lands on the live QR page.
*/
export async function pairAccountAction(formData: FormData): Promise<void> {
await rateLimit("pair");
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;
if (account.status === "connected") {
// Already connected — bounce to detail
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
await db
.update(whatsappAccounts)
.set({ status: "pending", lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, accountId));
await pgNotifyBot({ type: "account.start_pairing", accountId });
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}/pairing` as any);
}
/**
* Unpair: stop the Baileys session and clear session files via the bot,
* but KEEP the account row (status -> 'unpaired') so the operator can
* re-pair without retyping the label or losing any references.
*/
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 });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
// Wipe synced groups too — they belong to a different WA login now.
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
/**
* Permanently delete an account, its groups, reminders and run history
* via the cascade FKs added in migration 0003.
*/
export async function deleteAccountAction(formData: FormData): Promise<void> {
await rateLimit("delete-account");
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;
// Stop any live session / clean session files first.
await pgNotifyBot({ type: "account.unpair", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts");
// 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`);
}