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,
});
}
}