Files
myeasycms-v2/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx
Giancarlo Buomprisco 9033155fcd Add OTP sign-in option + Account Linking (#276)
* feat(accounts): allow linking email password
* feat(auth): add OTP sign-in
* refactor(accounts): remove 'sonner' dependency and update toast imports
* feat(supabase): enable analytics and configure database seeding
* feat(auth): update email templates and add OTP template
* feat(auth): add last sign in method hints
* feat(config): add devIndicators position to bottom-right
* feat(auth): implement comprehensive last authentication method tracking tests
2025-06-13 17:47:35 +08:00

278 lines
8.4 KiB
TypeScript

'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = string;
/**
* The maximum number of invites that can be sent at once.
* Useful to avoid spamming the server with too large payloads
*/
const MAX_INVITES = 5;
export function InviteMembersDialogContainer({
accountSlug,
userRoleHierarchy,
children,
}: React.PropsWithChildren<{
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation('teams');
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={pending}
roles={roles}
onSubmit={(data) => {
startTransition(() => {
const promise = createInvitationsAction({
accountSlug,
invitations: data.invitations,
});
toast.promise(() => promise, {
loading: t('invitingMembers'),
success: t('inviteMembersSuccessMessage'),
error: t('inviteMembersErrorMessage'),
});
setIsOpen(false);
});
}}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
}
function InviteMembersForm({
onSubmit,
roles,
pending,
}: {
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
roles: string[];
}) {
const { t } = useTranslation('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
shouldUseNativeValidation: true,
reValidateMode: 'onSubmit',
defaultValues: {
invitations: [createEmptyInviteModel()],
},
});
const fieldArray = useFieldArray({
control: form.control,
name: 'invitations',
});
return (
<Form {...form}>
<form
className={'flex flex-col space-y-8'}
data-test={'invite-members-form'}
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-y-4">
{fieldArray.fields.map((field, index) => {
const isFirst = index === 0;
const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const;
return (
<div data-test={'invite-member-form-item'} key={field.id}>
<div className={'flex items-end gap-x-1 md:space-x-2'}>
<div className={'w-7/12'}>
<FormField
name={emailInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>{t('emailLabel')}</FormLabel>
</If>
<FormControl>
<Input
data-test={'invite-email-input'}
placeholder={t('emailPlaceholder')}
type="email"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'w-4/12'}>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
</If>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0'}
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'flex w-[40px] items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4 lg:h-5'} />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('removeInviteButtonLabel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
})}
<If condition={fieldArray.fields.length < MAX_INVITES}>
<div>
<Button
data-test={'add-new-invite-button'}
type={'button'}
variant={'link'}
size={'sm'}
disabled={pending}
onClick={() => {
fieldArray.append(createEmptyInviteModel());
}}
>
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
</If>
</div>
<Button type={'submit'} disabled={pending}>
<Trans
i18nKey={
pending
? 'teams:invitingMembers'
: 'teams:inviteMembersButtonLabel'
}
/>
</Button>
</form>
</Form>
);
}
function createEmptyInviteModel() {
return { email: '', role: 'member' as Role };
}