Files
esign/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx
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

489 lines
17 KiB
TypeScript

import { useEffect, useMemo, useRef, 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 { OrganisationMemberRole } from '@prisma/client';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@hanzo/esign-lib/client-only/download-file';
import { useCurrentOrganisation } from '@hanzo/esign-lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@hanzo/esign-lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@hanzo/esign-lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@hanzo/esign-lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@hanzo/esign-lib/types/subscription';
import {
type TCreateOrganisationMemberInvitesRequestSchema,
ZCreateOrganisationMemberInvitesRequestSchema,
} from '@hanzo/esign-trpc/server/organisation-router/create-organisation-member-invites.types';
import type { TGetOrganisationResponse } from '@hanzo/esign-trpc/server/organisation-router/get-organisation.types';
import { useZapMutation, useZapQuery } from '@hanzo/esign-trpc/zap/react';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Alert, AlertDescription } from '@hanzo/esign-ui/primitives/alert';
import { Button } from '@hanzo/esign-ui/primitives/button';
import { Card, CardContent } from '@hanzo/esign-ui/primitives/card';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@hanzo/esign-ui/primitives/select';
import { SpinnerBox } from '@hanzo/esign-ui/primitives/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@hanzo/esign-ui/primitives/tabs';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
export type OrganisationMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogProps, 'children'>;
const ZInviteOrganisationMembersFormSchema = z
.object({
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, invitation] of items.invitations.entries()) {
const email = invitation.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['invitations', firstFoundIndex, 'email'],
});
}
});
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
export const OrganisationMemberInviteDialog = ({
trigger,
...props
}: OrganisationMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
const { _ } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const form = useForm<TInviteOrganisationMembersFormSchema>({
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
defaultValues: {
invitations: [
{
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
},
],
},
});
const {
append: appendOrganisationMemberInvite,
fields: organisationMemberInvites,
remove: removeOrganisationMemberInvite,
} = useFieldArray({
control: form.control,
name: 'invitations',
});
const { mutateAsync: createOrganisationMemberInvites } = useZapMutation<
void,
TCreateOrganisationMemberInvitesRequestSchema
>('organisation.member.invite.createMany');
const { data: fullOrganisation } = useZapQuery<TGetOrganisationResponse>('organisation.get', {
organisationReference: organisation.id,
});
const onAddOrganisationMemberInvite = () => {
appendOrganisationMemberInvite({
email: '',
organisationRole: OrganisationMemberRole.MEMBER,
});
};
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
try {
await createOrganisationMemberInvites({
organisationId: organisation.id,
invitations,
});
toast({
title: _(msg`Success`),
description: _(msg`Organisation invitations have been sent.`),
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
),
variant: 'destructive',
});
}
};
const dialogState = useMemo(() => {
if (!fullOrganisation) {
return 'loading';
}
if (!IS_BILLING_ENABLED()) {
return 'form';
}
if (fullOrganisation.organisationClaim.memberCount === 0) {
return 'form';
}
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
return 'form';
}
// This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert';
}
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
const csvFile = e.target.files[0];
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: 'Work email,Job title',
complete: (results: ParseResult<string[]>) => {
const members = results.data.map((row) => {
const [email, role] = row;
return {
email: email.trim(),
organisationRole: role.trim().toUpperCase(),
};
});
// Remove the first row if it contains the headers.
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
members.shift();
}
try {
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
form.setValue('invitations', importedInvitations);
form.clearErrors('invitations');
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
variant: 'destructive',
});
}
},
});
};
const downloadTemplate = () => {
const data = [
{ email: '[email protected]', role: 'Admin' },
{ email: '[email protected]', role: 'Manager' },
{ email: '[email protected]', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
});
downloadFile({
filename: 'sign-organisation-member-invites-template.csv',
data: blob,
});
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Invite member</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Invite organisation members</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>An email containing an invitation will be sent to each member.</Trans>
</DialogDescription>
</DialogHeader>
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<AlertDescription>
<Trans>
Your plan does not support inviting members. Please upgrade or your plan or
contact sales at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you
would like to discuss your options.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</DialogFooter>
</>
)}
{dialogState === 'form' && (
<Tabs
defaultValue="INDIVIDUAL"
value={invitationType}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{organisationMemberInvites.map((organisationMemberInvite, index) => (
<div
className="flex w-full flex-row space-x-4"
key={organisationMemberInvite.id}
>
<FormField
control={form.control}
name={`invitations.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email address</Trans>
</FormLabel>
)}
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invitations.${index}.organisationRole`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Organisation Role</Trans>
</FormLabel>
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={organisationMemberInvites.length === 1}
onClick={() => removeOrganisationMemberInvite(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="w-fit"
onClick={() => onAddOrganisationMemberInvite()}
>
<PlusCircle className="mr-2 h-4 w-4" />
<Trans>Add more</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
<Trans>Invite</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</TabsContent>
<TabsContent value="BULK">
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
<p className="mt-1 text-sm">
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="secondary" onClick={downloadTemplate}>
<Download className="mr-2 h-4 w-4" />
<Trans>Template</Trans>
</Button>
</DialogFooter>
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};