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 { notFound } from "next/navigation";
import { import {
UsersIcon, UsersIcon,
Trash2Icon,
ArrowLeftIcon, ArrowLeftIcon,
SmartphoneIcon, SmartphoneIcon,
CalendarIcon, CalendarIcon,
@ -10,7 +9,6 @@ import {
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
PowerIcon, PowerIcon,
PowerOffIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -20,23 +18,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
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 { import { pairAccountAction } from "@/actions/accounts";
unpairAccountAction, import { DeleteAccountCard } from "./delete-account-card";
pairAccountAction, import { UnpairAccountCard } from "./unpair-account-card";
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -156,110 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Card> </Card>
</Link> </Link>
{/* Unpair transparent <button> overlay (sibling of Card, <UnpairAccountCard accountId={account.id} accountLabel={account.label} />
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>
</> </>
)} )}
{/* Delete transparent <button> overlay opens the dialog. <DeleteAccountCard accountId={account.id} accountLabel={account.label} />
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>
</div> </div>
<Card> <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> <DialogHeader>
<DialogTitle>Forgot your password?</DialogTitle> <DialogTitle>Forgot your password?</DialogTitle>
<DialogDescription> <DialogDescription>
Self-service password reset is intentionally disabled on Contact your administrator to reset it.
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.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter showCloseButton> <DialogFooter showCloseButton>