Files
zeekayandHanzo Dev fa58fe3519 fix(dialogs): give the delete confirmations their message back
Six delete dialogs ask for a name typed exactly, as z.literal(name, {
errorMap: () => ({ message }) }). zod 4 took errorMap out of a schema's
params — it is `error` now — and ignores what it does not know, so since
the version sweep a wrong entry has shown zod's default, `Invalid input:
expected "delete acme"`, instead of "You must enter 'delete acme' to
proceed". The types caught it: the object literal no longer matched, and the
failed overload left the field's value typed as any literal, which the
input would not take.

The six build the field with createConfirmationSchema(name, message), which
spells it the zod 4 way. confirmation.test.ts checks the exact text passes
and near misses fail with the given message; with the errorMap spelling put
back, the second case fails on zod's default. zod 3 kept the message, so
this is the behaviour from before the sweep.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:38:33 -07:00

193 lines
5.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { createConfirmationSchema } from '@hanzo/esign-lib/types/confirmation';
import { useZapMutation } from '@hanzo/esign-trpc/zap/react';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@hanzo/esign-ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@hanzo/esign-ui/primitives/form/form';
import { Input } from '@hanzo/esign-ui/primitives/input';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`);
const ZTokenDeleteDialogSchema = z.object({
tokenName: createConfirmationSchema(
deleteMessage,
_(msg`You must enter '${deleteMessage}' to proceed`),
),
});
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
const { mutateAsync: deleteTokenMutation } = useZapMutation<void, { id: number; teamId: number }>(
'apiToken.delete',
{
onSuccess() {
onDelete?.();
},
},
);
const form = useForm<TDeleteTokenByIdMutationSchema>({
resolver: zodResolver(ZTokenDeleteDialogSchema),
values: {
tokenName: '',
},
});
const onSubmit = async () => {
try {
await deleteTokenMutation({
id: token.id,
teamId: team.id,
});
toast({
title: _(msg`Token deleted`),
description: _(msg`The token was deleted successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to delete this token. Please try again later.`,
),
variant: 'destructive',
duration: 5000,
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
<Trans>Delete</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure you want to delete this token?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your token will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setIsOpen(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}