fix(web): client-component delete/unpair cards on accounts/[id]

The DialogTrigger asChild + transparent button overlay pattern wasn't
emitting a clickable button in the rendered DOM under radix-ui 1.4 +
Next 16 (server component context), so Delete and Unpair both became
no-ops. Replace each with a small client component that:
  - holds open-state for the confirm Dialog
  - drives the Card itself as the click target via role='button',
    tabIndex, onClick, and Enter/Space keydown handlers
  - calls the server action through useTransition

The Card stays a div (no <button> wrapping a Card → satisfies the
existing static-guard test). Removed the unused inline Dialog imports
and unpair/delete icons from the page.

Also trim the forgot-password dialog body to one sentence per request
('don't write too detail').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 18:55:29 +08:00
parent 5d583d9194
commit 6759ca8131
4 changed files with 212 additions and 125 deletions

View File

@ -0,0 +1,104 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteAccountAction } from "@/actions/accounts";
interface DeleteAccountCardProps {
accountId: string;
accountLabel: string;
}
export function DeleteAccountCard({
accountId,
accountLabel,
}: DeleteAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await deleteAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Delete account"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">
Delete Account
</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,6 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import {
UsersIcon,
Trash2Icon,
ArrowLeftIcon,
SmartphoneIcon,
CalendarIcon,
@ -10,7 +9,6 @@ import {
DatabaseIcon,
PencilIcon,
PowerIcon,
PowerOffIcon,
ChevronRightIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -20,23 +18,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import {
unpairAccountAction,
pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
import { pairAccountAction } from "@/actions/accounts";
import { DeleteAccountCard } from "./delete-account-card";
import { UnpairAccountCard } from "./unpair-account-card";
interface AccountDetailPageProps {
params: Promise<{ id: string }>;
@ -156,110 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Card>
</Link>
{/* Unpair transparent <button> overlay (sibling of Card,
inside a relative wrapper). Same pattern as Delete below. */}
<Dialog>
<div className="relative">
<Card className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogTrigger asChild>
<button
type="button"
aria-label="Unpair WhatsApp"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="default" size="sm">
<PowerOffIcon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
<UnpairAccountCard accountId={account.id} accountLabel={account.label} />
</>
)}
{/* Delete transparent <button> overlay opens the dialog.
The button lives as a sibling of <Card> (inside a relative
wrapper) instead of inside the Card. Radix's asChild-driven
DialogTrigger stops emitting the underlying button when the
wrapper Card adds an `absolute inset-0` sibling on the same
stacking context, so we mirror the working pattern from the
Pair/Re-pair card above. */}
<Dialog>
<div className="relative">
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogTrigger asChild>
<button
type="button"
aria-label="Delete account"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
/>
</DialogTrigger>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
</div>
<Card>

View File

@ -0,0 +1,102 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unpairAccountAction } from "@/actions/accounts";
interface UnpairAccountCardProps {
accountId: string;
accountLabel: string;
}
export function UnpairAccountCard({
accountId,
accountLabel,
}: UnpairAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await unpairAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Unpair WhatsApp"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not
deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PowerOffIcon className="size-4" />
)}
Yes, unpair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -84,14 +84,7 @@ export function LoginFormClient({ next }: { next: string }) {
<DialogHeader>
<DialogTitle>Forgot your password?</DialogTitle>
<DialogDescription>
Self-service password reset is intentionally disabled on
this deployment. To recover access, contact an
administrator. They can reset your password from
Settings Users, or run{" "}
<code className="font-mono text-[0.75rem]">
./scripts/set-password.sh &lt;username&gt;
</code>{" "}
from the tools container.
Contact your administrator to reset it.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>