Files
zeekay 76ce46705f ui: confine the Radix substrate to packages/ui and drop the dead shadcn copies
Radix was leaking out of the primitive layer into application code. Thirty
dialog components under apps/remix imported `@radix-ui/react-dialog` purely to
spell `Omit<DialogPrimitive.DialogProps, 'children'>`, and two more imported
`TooltipArrow` directly. Each of those is a place the UI substrate has to be
swapped when it changes, for no gain — the app never touched Radix behaviour,
only a type and one arrow element.

`packages/ui/primitives/dialog.tsx` now exports `DialogProps` and
`tooltip.tsx` exports `TooltipArrow`, so callers name the primitive, not the
library behind it. Radix imports outside packages/ui: 32 -> 0.

Eleven vendored shadcn primitives had no importers at all — aspect-ratio,
auto-sized-text, calendar, collapsible, context-menu, menubar,
navigation-menu, slider, toggle, a stray document-flow dialog and a
signature-pad Canvas class shadowed by skia-canvas. Dead component code reads
as supported API in review, so it is deleted rather than carried. That
retires eight Radix packages plus react-day-picker from packages/ui and
@radix-ui/react-tabs from apps/docs, which was declared and never imported:
28 declared Radix packages -> 19.

The embedding doc taught Tailwind classes in a snippet third parties copy into
their own apps; it now uses the same inline styles as the plain-HTML example
directly above it. `.cursorrules` told every AI touching this repo to write
"Shadcn UI, Radix, and Tailwind Aria" — it now points at @hanzo/gui and says
the inherited layer is to be replaced, not extended.

Verified: apps/remix typecheck 0 errors, client+server build green,
prisma 38/38.
2026-08-13 12:04:22 -07:00

271 lines
8.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
import { MAXIMUM_PASSKEYS } from '@hanzo/esign-lib/constants/auth';
import { AppError } from '@hanzo/esign-lib/errors/app-error';
import type { TCreatePasskeyRegistrationOptionsResponse } from '@hanzo/esign-trpc/server/auth-router/create-passkey-registration-options.types';
import type { TCreatePasskeyRequest } from '@hanzo/esign-trpc/server/auth-router/create-passkey.types';
import { useZapMutation } from '@hanzo/esign-trpc/zap/react';
import { Alert, AlertDescription, AlertTitle } from '@hanzo/esign-ui/primitives/alert';
import { Button } from '@hanzo/esign-ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
type DialogProps,
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';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3),
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser();
export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { t } = useLingui();
const { toast } = useToast();
const form = useForm<TCreatePasskeyFormSchema>({
resolver: zodResolver(ZCreatePasskeyFormSchema),
defaultValues: {
passkeyName: '',
},
});
const { mutateAsync: createPasskeyRegistrationOptions, isPending } = useZapMutation<
TCreatePasskeyRegistrationOptionsResponse,
void
>('auth.passkey.createRegistrationOptions');
const { mutateAsync: createPasskey } = useZapMutation<void, TCreatePasskeyRequest>(
'auth.passkey.create',
);
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
setFormError(null);
try {
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
const registrationResult = await startRegistration({
optionsJSON: passkeyRegistrationOptions,
});
await createPasskey({
passkeyName,
verificationResponse: registrationResult,
});
toast({
description: t`Successfully created passkey`,
duration: 5000,
});
onSuccess?.();
setOpen(false);
} catch (err) {
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
setFormError(err.code || error.code);
}
};
const extractDefaultPasskeyName = () => {
if (!window || !window.navigator) {
return;
}
parser.setUA(window.navigator.userAgent);
const result = parser.getResult();
const operatingSystem = result.os.name;
const browser = result.browser.name;
let passkeyName = '';
if (operatingSystem && browser) {
passkeyName = `${browser} (${operatingSystem})`;
}
return passkeyName;
};
useEffect(() => {
if (!open) {
const defaultPasskeyName = extractDefaultPasskeyName();
form.reset({
passkeyName: defaultPasskeyName,
});
setFormError(null);
}
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="secondary" loading={isPending}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
<Trans>Add passkey</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Add passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
Passkeys allow you to sign in and authenticate using biometrics, password managers,
etc.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="passkeyName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Passkey name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`eg. Mac`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Alert variant="neutral">
<AlertDescription>
<Trans>
When you click continue, you will be prompted to add the first available
authenticator on your system.
</Trans>
</AlertDescription>
<AlertDescription className="mt-2">
<Trans>
If you do not want to use the authenticator prompted, you can close it, which
will then display the next available authenticator.
</Trans>
</AlertDescription>
</Alert>
{formError && (
<Alert variant="destructive">
{match(formError)
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
<AlertDescription>
<Trans>This passkey has already been registered.</Trans>
</AlertDescription>
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
<Plural
value={MAXIMUM_PASSKEYS}
one="You cannot have more than # passkey."
other="You cannot have more than # passkeys."
/>
</AlertDescription>
))
.with('InvalidStateError', () => (
<>
<AlertTitle className="text-sm">
<Trans>
Passkey creation cancelled due to one of the following reasons:
</Trans>
</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-inside list-disc">
<li>
<Trans>Cancelled by user</Trans>
</li>
<li>
<Trans>Passkey already exists for the provided authenticator</Trans>
</li>
<li>
<Trans>Exceeded timeout</Trans>
</li>
</ul>
</AlertDescription>
</>
))
.otherwise(() => (
<AlertDescription>
<Trans>Something went wrong. Please try again or contact support.</Trans>
</AlertDescription>
))}
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};