2.18.0: New Invitation flow, refactored Database Webhooks, new ShadCN UI Components (#384)

* Streamlined invitations flow
* Removed web hooks in favor of handling logic directly in server actions
* Added new Shadcn UI Components
This commit is contained in:
Giancarlo Buomprisco
2025-10-05 17:54:16 +08:00
committed by GitHub
parent 195cf41680
commit 2e20d3e76f
60 changed files with 3760 additions and 1009 deletions

View File

@@ -2,6 +2,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -13,11 +14,14 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
@@ -89,50 +93,57 @@ export function UpdateEmailForm({
</If>
<div className={'flex flex-col space-y-4'}>
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:newEmail'} />
</FormLabel>
<div className="flex flex-col space-y-2">
<FormField
render={({ field }) => (
<FormItem>
<FormControl>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Mail className="h-4 w-4" />
</InputGroupAddon>
<FormControl>
<Input
data-test={'account-email-form-email-input'}
required
type={'email'}
placeholder={''}
{...field}
/>
</FormControl>
<InputGroupInput
data-test={'account-email-form-email-input'}
required
type={'email'}
placeholder={t('account:newEmail')}
{...field}
/>
</InputGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
name={'email'}
/>
<FormMessage />
</FormItem>
)}
name={'email'}
/>
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:repeatEmail'} />
</FormLabel>
<FormField
render={({ field }) => (
<FormItem>
<FormControl>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Mail className="h-4 w-4" />
</InputGroupAddon>
<FormControl>
<Input
{...field}
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
/>
</FormControl>
<InputGroupInput
{...field}
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
placeholder={t('account:repeatEmail')}
/>
</InputGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
name={'repeatEmail'}
/>
<FormMessage />
</FormItem>
)}
name={'repeatEmail'}
/>
</div>
<div>
<Button disabled={updateUserMutation.isPending}>

View File

@@ -20,6 +20,15 @@ import {
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from '@kit/ui/item';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { Separator } from '@kit/ui/separator';
import { toast } from '@kit/ui/sonner';
@@ -90,73 +99,76 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
<div className="flex flex-col space-y-2">
{connectedIdentities.map((identity) => (
<div
key={identity.id}
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Item key={identity.id} variant="outline">
<ItemMedia>
<OauthProviderLogoImage providerId={identity.provider} />
</ItemMedia>
<div className="flex flex-col">
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
<CheckCircle className="h-3 w-3 text-green-500" />
<ItemContent>
<ItemHeader className="flex items-center gap-3">
<div className="flex flex-col">
<ItemTitle className="flex items-center gap-x-2 text-sm font-medium capitalize">
<CheckCircle className="h-3 w-3 text-green-500" />
<span>{identity.provider}</span>
</span>
<span>{identity.provider}</span>
</ItemTitle>
<If condition={identity.identity_data?.email}>
<span className="text-muted-foreground text-xs">
{identity.identity_data?.email as string}
</span>
</If>
</div>
</div>
<If condition={identity.identity_data?.email}>
<ItemDescription>
{identity.identity_data?.email as string}
</ItemDescription>
</If>
</div>
</ItemHeader>
</ItemContent>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account:unlinkAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:confirmUnlinkAccount'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'account:unlinkAccountConfirmation'}
values={{ provider: identity.provider }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlinkAccount(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
<ItemActions>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account:unlinkAccount'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</If>
</div>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:confirmUnlinkAccount'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'account:unlinkAccountConfirmation'}
values={{ provider: identity.provider }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlinkAccount(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trans i18nKey={'account:unlinkAccount'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</If>
</ItemActions>
</Item>
))}
</div>
</div>
@@ -179,19 +191,28 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
<div className="flex flex-col space-y-2">
{availableProviders.map((provider) => (
<button
<Item
key={provider}
className="hover:bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3 transition-colors"
variant="outline"
onClick={() => handleLinkAccount(provider)}
role="button"
className="hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<ItemMedia>
<OauthProviderLogoImage providerId={provider} />
</ItemMedia>
<span className="text-sm font-medium capitalize">
{provider}
</span>
</div>
</button>
<ItemContent>
<ItemTitle className="capitalize">{provider}</ItemTitle>
<ItemDescription>
<Trans
i18nKey={'account:linkAccountDescription'}
values={{ provider }}
/>
</ItemDescription>
</ItemContent>
</Item>
))}
</div>
</div>

View File

@@ -26,6 +26,13 @@ import {
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@kit/ui/item';
import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner';
import {
@@ -100,17 +107,21 @@ function FactorsTableContainer(props: { userId: string }) {
if (!allFactors.length) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert>
<ShieldCheck className={'h-4'} />
<Item variant="outline">
<ItemMedia>
<ShieldCheck className={'h-4'} />
</ItemMedia>
<AlertTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</AlertTitle>
<ItemContent>
<ItemTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</ItemTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</AlertDescription>
</Alert>
<ItemDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</ItemDescription>
</ItemContent>
</Item>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react';
import { Check, Lock } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -17,12 +17,14 @@ import {
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
@@ -101,62 +103,68 @@ export const UpdatePasswordForm = ({
<NeedsReauthenticationAlert />
</If>
<FormField
name={'newPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:newPassword'} />
</Label>
</FormLabel>
<div className="flex flex-col space-y-2">
<FormField
name={'newPassword'}
render={({ field }) => {
return (
<FormItem>
<FormControl>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Lock className="h-4 w-4" />
</InputGroupAddon>
<FormControl>
<Input
data-test={'account-password-form-password-input'}
autoComplete={'new-password'}
required
type={'password'}
{...field}
/>
</FormControl>
<InputGroupInput
data-test={'account-password-form-password-input'}
autoComplete={'new-password'}
required
type={'password'}
placeholder={t('account:newPassword')}
{...field}
/>
</InputGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'repeatPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:repeatPassword'} />
</Label>
</FormLabel>
<FormField
name={'repeatPassword'}
render={({ field }) => {
return (
<FormItem>
<FormControl>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Lock className="h-4 w-4" />
</InputGroupAddon>
<FormControl>
<Input
data-test={'account-password-form-repeat-password-input'}
required
type={'password'}
{...field}
/>
</FormControl>
<InputGroupInput
data-test={
'account-password-form-repeat-password-input'
}
required
type={'password'}
placeholder={t('account:repeatPassword')}
{...field}
/>
</InputGroup>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div>
<Button disabled={updateUserMutation.isPending}>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { User } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -9,10 +10,13 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
@@ -66,18 +70,20 @@ export function UpdateAccountDetailsForm({
name={'displayName'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:name'} />
</FormLabel>
<FormControl>
<Input
data-test={'account-display-name'}
minLength={2}
placeholder={''}
maxLength={100}
{...field}
/>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<User className="h-4 w-4" />
</InputGroupAddon>
<InputGroupInput
data-test={'account-display-name'}
minLength={2}
placeholder={t('account:name')}
maxLength={100}
{...field}
/>
</InputGroup>
</FormControl>
<FormMessage />

View File

@@ -85,8 +85,10 @@ export const deletePersonalAccountAction = enhanceAction(
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({
adminClient: getSupabaseServerAdminClient(),
userId: user.id,
userEmail: user.email ?? null,
account: {
id: user.id,
email: user.email ?? null,
},
});
// sign out the user after deleting their account

View File

@@ -2,6 +2,8 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -30,13 +32,14 @@ class DeletePersonalAccountService {
*/
async deletePersonalAccount(params: {
adminClient: SupabaseClient<Database>;
userId: string;
userEmail: string | null;
account: {
id: string;
email: string | null;
};
}) {
const logger = await getLogger();
const userId = params.userId;
const userId = params.account.id;
const ctx = { userId, name: this.namespace };
logger.info(
@@ -54,6 +57,14 @@ class DeletePersonalAccountService {
logger.info(ctx, 'User successfully deleted!');
if (params.account.email) {
// dispatch the delete account email. Errors are handled in the method.
await this.dispatchDeleteAccountEmail({
email: params.account.email,
id: params.account.id,
});
}
return {
success: true,
};
@@ -69,4 +80,71 @@ class DeletePersonalAccountService {
throw new Error('Error deleting user');
}
}
private async dispatchDeleteAccountEmail(account: {
email: string;
id: string;
}) {
const logger = await getLogger();
const ctx = { name: this.namespace, userId: account.id };
try {
logger.info(ctx, 'Sending delete account email...');
await this.sendDeleteAccountEmail(account);
logger.info(ctx, 'Delete account email sent successfully');
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to send delete account email',
);
}
}
private async sendDeleteAccountEmail(account: { email: string }) {
const emailSettings = this.getEmailSettings();
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderAccountDeleteEmail({
productName: emailSettings.productName,
});
await mailer.sendEmail({
from: emailSettings.fromEmail,
html,
subject,
to: account.email,
});
}
private getEmailSettings() {
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
const fromEmail = process.env.EMAIL_SENDER;
return z
.object({
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
fromEmail: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
productName,
fromEmail,
});
}
}

View File

@@ -28,13 +28,11 @@ import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
export function MagicLinkAuthContainer({
inviteToken,
redirectUrl,
shouldCreateUser,
defaultValues,
displayTermsCheckbox,
}: {
inviteToken?: string;
redirectUrl: string;
shouldCreateUser: boolean;
displayTermsCheckbox?: boolean;
@@ -63,10 +61,6 @@ export function MagicLinkAuthContainer({
const onSubmit = ({ email }: { email: string }) => {
const url = new URL(redirectUrl);
if (inviteToken) {
url.searchParams.set('invite_token', inviteToken);
}
const emailRedirectTo = url.href;
const promise = async () => {

View File

@@ -32,7 +32,6 @@ const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
};
export const OauthProviders: React.FC<{
inviteToken?: string;
shouldCreateUser: boolean;
enabledProviders: Provider[];
queryParams?: Record<string, string>;
@@ -86,10 +85,6 @@ export const OauthProviders: React.FC<{
queryParams.set('next', props.paths.returnPath);
}
if (props.inviteToken) {
queryParams.set('invite_token', props.inviteToken);
}
const redirectPath = [
props.paths.callback,
queryParams.toString(),

View File

@@ -36,7 +36,6 @@ const OtpSchema = z.object({ token: z.string().min(6).max(6) });
type OtpSignInContainerProps = {
shouldCreateUser: boolean;
inviteToken?: string;
};
export function OtpSignInContainer(props: OtpSignInContainerProps) {
@@ -80,19 +79,9 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
recordAuthMethod('otp', { email });
// on sign ups we redirect to the app home
const inviteToken = props.inviteToken;
const next = params.get('next') ?? '/home';
if (inviteToken) {
const params = new URLSearchParams({
invite_token: inviteToken,
next,
});
router.replace(`/join?${params.toString()}`);
} else {
router.replace(next);
}
router.replace(next);
};
if (isEmailStep) {

View File

@@ -18,8 +18,6 @@ import { OtpSignInContainer } from './otp-sign-in-container';
import { PasswordSignInContainer } from './password-sign-in-container';
export function SignInMethodsContainer(props: {
inviteToken?: string;
paths: {
callback: string;
joinTeam: string;
@@ -40,22 +38,10 @@ export function SignInMethodsContainer(props: {
: '';
const onSignIn = useCallback(() => {
// if the user has an invite token, we should join the team
if (props.inviteToken) {
const searchParams = new URLSearchParams({
invite_token: props.inviteToken,
});
const returnPath = props.paths.returnPath || '/home';
const joinTeamPath = props.paths.joinTeam + '?' + searchParams.toString();
router.replace(joinTeamPath);
} else {
const returnPath = props.paths.returnPath || '/home';
// otherwise, we should redirect to the return path
router.replace(returnPath);
}
}, [props.inviteToken, props.paths.joinTeam, props.paths.returnPath, router]);
router.replace(returnPath);
}, [props.paths.returnPath, router]);
return (
<>
@@ -67,17 +53,13 @@ export function SignInMethodsContainer(props: {
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}
redirectUrl={redirectUrl}
shouldCreateUser={false}
/>
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer
inviteToken={props.inviteToken}
shouldCreateUser={false}
/>
<OtpSignInContainer shouldCreateUser={false} />
</If>
<If condition={props.providers.oAuth.length}>
@@ -95,7 +77,6 @@ export function SignInMethodsContainer(props: {
<OauthProviders
enabledProviders={props.providers.oAuth}
inviteToken={props.inviteToken}
shouldCreateUser={false}
paths={{
callback: props.paths.callback,

View File

@@ -3,7 +3,6 @@
import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@kit/shared/utils';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
@@ -28,7 +27,6 @@ export function SignUpMethodsContainer(props: {
};
displayTermsCheckbox?: boolean;
inviteToken?: string;
}) {
const redirectUrl = getCallbackUrl(props);
const defaultValues = getDefaultValues();
@@ -38,10 +36,6 @@ export function SignUpMethodsContainer(props: {
{/* Show hint if user might already have an account */}
<ExistingAccountHint />
<If condition={props.inviteToken}>
<InviteAlert />
</If>
<If condition={props.providers.password}>
<EmailPasswordSignUpContainer
emailRedirectTo={redirectUrl}
@@ -51,15 +45,11 @@ export function SignUpMethodsContainer(props: {
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer
inviteToken={props.inviteToken}
shouldCreateUser={true}
/>
<OtpSignInContainer shouldCreateUser={true} />
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}
redirectUrl={redirectUrl}
shouldCreateUser={true}
defaultValues={defaultValues}
@@ -82,7 +72,6 @@ export function SignUpMethodsContainer(props: {
<OauthProviders
enabledProviders={props.providers.oAuth}
inviteToken={props.inviteToken}
shouldCreateUser={true}
paths={{
callback: props.paths.callback,
@@ -99,8 +88,6 @@ function getCallbackUrl(props: {
callback: string;
appHome: string;
};
inviteToken?: string;
}) {
if (!isBrowser()) {
return '';
@@ -110,10 +97,6 @@ function getCallbackUrl(props: {
const origin = window.location.origin;
const url = new URL(redirectPath, origin);
if (props.inviteToken) {
url.searchParams.set('invite_token', props.inviteToken);
}
const searchParams = new URLSearchParams(window.location.search);
const next = searchParams.get('next');
@@ -130,27 +113,8 @@ function getDefaultValues() {
}
const searchParams = new URLSearchParams(window.location.search);
const inviteToken = searchParams.get('invite_token');
if (!inviteToken) {
return { email: '' };
}
return {
email: searchParams.get('email') ?? '',
};
}
function InviteAlert() {
return (
<Alert variant={'info'}>
<AlertTitle>
<Trans i18nKey={'auth:inviteAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:inviteAlertBody'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Plus, X } from 'lucide-react';
import { Mail, Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -23,11 +23,14 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner';
import {
@@ -188,28 +191,26 @@ function InviteMembersForm({
data-test={'invite-members-form'}
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2.5">
{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'}>
<div className={'flex items-end gap-x-2'}>
<InputGroup className={'bg-background w-full'}>
<InputGroupAddon align="inline-start">
<Mail className="h-4 w-4" />
</InputGroupAddon>
<FormField
name={emailInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>{t('emailLabel')}</FormLabel>
</If>
<FormItem className="w-full">
<FormControl>
<Input
<InputGroupInput
data-test={'invite-email-input'}
placeholder={t('emailPlaceholder')}
type="email"
@@ -223,39 +224,31 @@ function InviteMembersForm({
);
}}
/>
</div>
</InputGroup>
<div className={'w-4/12'}>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
</FormLabel>
</If>
<FormField
name={roleInputName}
render={({ field }) => {
return (
<FormItem>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0 bg-muted'}
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
<FormControl>
<MembershipRoleSelector
triggerClassName={'m-0'}
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'flex w-[40px] items-end justify-end'}>
<div className={'flex items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -271,7 +264,7 @@ function InviteMembersForm({
form.clearErrors(emailInputName);
}}
>
<X className={'h-4 lg:h-5'} />
<X className={'h-4'} />
</Button>
</TooltipTrigger>

View File

@@ -5,6 +5,7 @@ import { useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { Building } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -14,10 +15,13 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@kit/ui/input-group';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
@@ -87,17 +91,19 @@ export const UpdateTeamAccountNameForm = (props: {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameInputLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-name-input'}
required
placeholder={''}
{...field}
/>
<InputGroup className="dark:bg-background">
<InputGroupAddon align="inline-start">
<Building className="h-4 w-4" />
</InputGroupAddon>
<InputGroupInput
data-test={'team-name-input'}
required
placeholder={t('teams:teamNameInputLabel')}
{...field}
/>
</InputGroup>
</FormControl>
<FormMessage />

View File

@@ -145,6 +145,7 @@ export const acceptInvitationAction = enhanceAction(
const accountId = await service.acceptInvitationToTeam(adminClient, {
inviteToken,
userId: user.id,
userEmail: user.email,
});
// If the account ID is not present, throw an error

View File

@@ -209,6 +209,8 @@ export class TeamAccountsApi {
string,
{
id: string;
email: string;
account: {
id: string;
name: string;
@@ -217,7 +219,10 @@ export class TeamAccountsApi {
};
}
>(
'id, expires_at, account: account_id !inner (id, name, slug, picture_url)',
`id,
expires_at,
email,
account: account_id !inner (id, name, slug, picture_url)`,
)
.eq('invite_token', token)
.gte('expires_at', new Date().toISOString())

View File

@@ -0,0 +1,253 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database, Tables } from '@kit/supabase/database';
type Invitation = Tables<'invitations'>;
const invitePath = '/join';
const authTokenCallbackPath = '/auth/confirm';
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
const emailSender = process.env.EMAIL_SENDER;
const env = z
.object({
invitePath: z
.string({
required_error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
invitePath,
siteURL,
productName,
emailSender,
});
export function createAccountInvitationsDispatchService(
client: SupabaseClient<Database>,
) {
return new AccountInvitationsDispatchService(client);
}
class AccountInvitationsDispatchService {
private namespace = 'accounts.invitations.webhook';
constructor(private readonly adminClient: SupabaseClient<Database>) {}
/**
* @name sendInvitationEmail
* @description Sends an invitation email to the invited user
* @param invitation - The invitation to send
* @returns
*/
async sendInvitationEmail({
invitation,
link,
}: {
invitation: Invitation;
link: string;
}) {
const logger = await getLogger();
logger.info(
{
invitationId: invitation.id,
name: this.namespace,
},
'Handling invitation email dispatch...',
);
// retrieve the inviter details
const inviter = await this.getInviterDetails(invitation);
if (inviter.error) {
logger.error(
{
error: inviter.error,
name: this.namespace,
},
'Failed to fetch inviter details',
);
throw inviter.error;
}
// retrieve the team details
const team = await this.getTeamDetails(invitation.account_id);
if (team.error) {
logger.error(
{
error: team.error,
name: this.namespace,
},
'Failed to fetch team details',
);
throw team.error;
}
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
try {
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
// send the invitation email
await this.sendEmail({
invitation,
link,
inviter: inviter.data,
team: team.data,
});
return {
success: true,
};
} catch (error) {
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
return {
error,
success: false,
};
}
}
/**
* @name getInvitationLink
* @description Generates an invitation link for the given token and email
* @param token - The token to use for the invitation
*/
getInvitationLink(token: string) {
const siteUrl = env.siteURL;
const url = new URL(env.invitePath, siteUrl);
url.searchParams.set('invite_token', token);
return url.href;
}
/**
* @name sendEmail
* @description Sends an invitation email to the invited user
* @param invitation - The invitation to send
* @param link - The link to the invitation
* @param inviter - The inviter details
* @param team - The team details
* @returns
*/
private async sendEmail({
invitation,
link,
inviter,
team,
}: {
invitation: Invitation;
link: string;
inviter: { name: string; email: string | null };
team: { name: string };
}) {
const logger = await getLogger();
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
const { renderInviteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderInviteEmail({
link,
invitedUserEmail: invitation.email,
inviter: inviter.name ?? inviter.email ?? '',
productName: env.productName,
teamName: team.name,
});
return mailer
.sendEmail({
from: env.emailSender,
to: invitation.email,
subject,
html,
})
.then(() => {
logger.info(ctx, 'Invitation email successfully sent!');
})
.catch((error) => {
console.error(error);
logger.error({ error, ...ctx }, 'Failed to send invitation email');
});
}
/**
* @name getAuthCallbackUrl
* @description Generates an auth token callback url. This redirects the user to a page where the user can sign in with a token.
* @param nextLink - The next link to redirect the user to
* @returns
*/
getAuthCallbackUrl(nextLink: string) {
const url = new URL(authTokenCallbackPath, env.siteURL);
url.searchParams.set('next', nextLink);
return url;
}
/**
* @name getInviterDetails
* @description Fetches the inviter details for the given invitation
* @param invitation
* @returns
*/
private getInviterDetails(invitation: Invitation) {
return this.adminClient
.from('accounts')
.select('email, name')
.eq('id', invitation.invited_by)
.single();
}
/**
* @name getTeamDetails
* @description Fetches the team details for the given account ID
* @param accountId
* @returns
*/
private getTeamDetails(accountId: string) {
return this.adminClient
.from('accounts')
.select('name')
.eq('id', accountId)
.single();
}
}

View File

@@ -7,10 +7,12 @@ import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsDispatchService } from './account-invitations-dispatcher.service';
/**
*
@@ -212,6 +214,8 @@ class AccountInvitationsService {
},
'Invitations added to account',
);
await this.dispatchInvitationEmails(ctx, responseInvitations);
}
/**
@@ -222,10 +226,12 @@ class AccountInvitationsService {
adminClient: SupabaseClient<Database>,
params: {
userId: string;
userEmail: string;
inviteToken: string;
},
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
@@ -233,6 +239,30 @@ class AccountInvitationsService {
logger.info(ctx, 'Accepting invitation to team');
const invitation = await adminClient
.from('invitations')
.select('email')
.eq('invite_token', params.inviteToken)
.single();
if (invitation.error) {
logger.error(
{
...ctx,
error: invitation.error,
},
'Failed to get invitation',
);
}
// if the invitation email does not match the user email, throw an error
if (invitation.data?.email !== params.userEmail) {
logger.error({
...ctx,
error: 'Invitation email does not match user email',
});
}
const { error, data } = await adminClient.rpc('accept_invitation', {
token: params.inviteToken,
user_id: params.userId,
@@ -297,4 +327,128 @@ class AccountInvitationsService {
return data;
}
/**
* @name dispatchInvitationEmails
* @description Dispatches invitation emails to the invited users.
* @param ctx
* @param invitations
* @returns
*/
private async dispatchInvitationEmails(
ctx: { accountSlug: string; name: string },
invitations: Database['public']['Tables']['invitations']['Row'][],
) {
if (!invitations.length) {
return;
}
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = createAccountInvitationsDispatchService(this.client);
const results = await Promise.allSettled(
invitations.map(async (invitation) => {
const joinTeamLink = service.getInvitationLink(invitation.invite_token);
const authCallbackUrl = service.getAuthCallbackUrl(joinTeamLink);
const getEmailLinkType = async () => {
const user = await adminClient
.from('accounts')
.select('*')
.eq('email', invitation.email)
.single();
// if the user is not found, return the invite type
// this link allows the user to register to the platform
if (user.error || !user.data) {
return 'invite';
}
// if the user is found, return the email link type to sign in
return 'magiclink';
};
const emailLinkType = await getEmailLinkType();
// generate an invitation link with Supabase admin client
// use the "redirectTo" parameter to redirect the user to the invitation page after the link is clicked
const generateLinkResponse = await adminClient.auth.admin.generateLink({
email: invitation.email,
type: emailLinkType,
});
// if the link generation fails, throw an error
if (generateLinkResponse.error) {
logger.error(
{
...ctx,
error: generateLinkResponse.error,
},
'Failed to generate link',
);
throw generateLinkResponse.error;
}
// get the link from the response
const verifyLink = generateLinkResponse.data.properties?.action_link;
// extract token
const token = new URL(verifyLink).searchParams.get('token');
if (!token) {
// return error
throw new Error(
'Token in verify link from Supabase Auth was not found',
);
}
// add search params to be consumed by /auth/confirm route
authCallbackUrl.searchParams.set('token_hash', token);
authCallbackUrl.searchParams.set('type', emailLinkType);
const link = authCallbackUrl.href;
// send the invitation email
const data = await service.sendInvitationEmail({
invitation,
link,
});
// return the result
return {
id: invitation.id,
data,
};
}),
);
for (const result of results) {
if (result.status !== 'fulfilled' || !result.value.data.success) {
logger.error(
{
...ctx,
invitationId:
result.status === 'fulfilled' ? result.value.id : result.reason,
},
'Failed to send invitation email',
);
}
}
const succeeded = results.filter(
(result) => result.status === 'fulfilled' && result.value.data.success,
);
if (succeeded.length) {
logger.info(
{
...ctx,
count: succeeded.length,
},
'Invitation emails successfully sent!',
);
}
}
}

View File

@@ -1,175 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database, Tables } from '@kit/supabase/database';
type Invitation = Tables<'invitations'>;
const invitePath = '/join';
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
const emailSender = process.env.EMAIL_SENDER;
const env = z
.object({
invitePath: z
.string({
required_error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
invitePath,
siteURL,
productName,
emailSender,
});
export function createAccountInvitationsWebhookService(
client: SupabaseClient<Database>,
) {
return new AccountInvitationsWebhookService(client);
}
class AccountInvitationsWebhookService {
private namespace = 'accounts.invitations.webhook';
constructor(private readonly adminClient: SupabaseClient<Database>) {}
/**
* @name handleInvitationWebhook
* @description Handles the webhook event for invitations
* @param invitation
*/
async handleInvitationWebhook(invitation: Invitation) {
return this.dispatchInvitationEmail(invitation);
}
private async dispatchInvitationEmail(invitation: Invitation) {
const logger = await getLogger();
logger.info(
{ invitation, name: this.namespace },
'Handling invitation webhook event...',
);
const inviter = await this.adminClient
.from('accounts')
.select('email, name')
.eq('id', invitation.invited_by)
.single();
if (inviter.error) {
logger.error(
{
error: inviter.error,
name: this.namespace,
},
'Failed to fetch inviter details',
);
throw inviter.error;
}
const team = await this.adminClient
.from('accounts')
.select('name')
.eq('id', invitation.account_id)
.single();
if (team.error) {
logger.error(
{
error: team.error,
name: this.namespace,
},
'Failed to fetch team details',
);
throw team.error;
}
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
try {
const { renderInviteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const link = this.getInvitationLink(
invitation.invite_token,
invitation.email,
);
const { html, subject } = await renderInviteEmail({
link,
invitedUserEmail: invitation.email,
inviter: inviter.data.name ?? inviter.data.email ?? '',
productName: env.productName,
teamName: team.data.name,
});
await mailer
.sendEmail({
from: env.emailSender,
to: invitation.email,
subject,
html,
})
.then(() => {
logger.info(ctx, 'Invitation email successfully sent!');
})
.catch((error) => {
console.error(error);
logger.error({ error, ...ctx }, 'Failed to send invitation email');
});
return {
success: true,
};
} catch (error) {
console.error(error);
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
return {
error,
success: false,
};
}
}
private getInvitationLink(token: string, email: string) {
const searchParams = new URLSearchParams({
invite_token: token,
email,
}).toString();
const href = new URL(env.invitePath, env.siteURL).href;
return `${href}?${searchParams}`;
}
}

View File

@@ -1,90 +0,0 @@
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Tables } from '@kit/supabase/database';
type Account = Tables<'accounts'>;
export function createAccountWebhooksService() {
return new AccountWebhooksService();
}
class AccountWebhooksService {
private readonly namespace = 'accounts.webhooks';
async handleAccountDeletedWebhook(account: Account) {
const logger = await getLogger();
const ctx = {
accountId: account.id,
namespace: this.namespace,
};
logger.info(ctx, 'Received account deleted webhook. Processing...');
if (account.is_personal_account) {
logger.info(ctx, `Account is personal. We send an email to the user.`);
await this.sendDeleteAccountEmail(account);
}
}
private async sendDeleteAccountEmail(account: Account) {
const userEmail = account.email;
const userDisplayName = account.name ?? userEmail;
const emailSettings = this.getEmailSettings();
if (userEmail) {
await this.sendAccountDeletionEmail({
fromEmail: emailSettings.fromEmail,
productName: emailSettings.productName,
userDisplayName,
userEmail,
});
}
}
private async sendAccountDeletionEmail(params: {
fromEmail: string;
userEmail: string;
userDisplayName: string;
productName: string;
}) {
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
return mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject,
html,
});
}
private getEmailSettings() {
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
const fromEmail = process.env.EMAIL_SENDER;
return z
.object({
productName: z.string(),
fromEmail: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
productName,
fromEmail,
});
}
}

View File

@@ -1,2 +0,0 @@
export * from './account-webhooks.service';
export * from './account-invitations-webhook.service';