Updated account deletion process and refactor packages

The primary update was on the process of account deletion where email notifications are now sent to users. The @kit/emails was also renamed to @kit/email-templates and adjustments were accordingly made on the relevant code and configuration files. In addition, package interaction was refactored to enhance readability and ease of maintenance. Some minor alterations were made on the User Interface, and code comments were updated.
This commit is contained in:
giancarlo
2024-03-28 11:20:12 +08:00
parent 6048cc4759
commit 3ac4d3b00d
30 changed files with 290 additions and 264 deletions

View File

@@ -19,7 +19,7 @@
"@kit/stripe": "0.1.0",
"@kit/supabase": "^0.1.0",
"@kit/ui": "0.1.0",
"@supabase/supabase-js": "^2.39.8",
"@supabase/supabase-js": "^2.40.0",
"lucide-react": "^0.363.0",
"zod": "^3.22.4"
},
@@ -33,7 +33,7 @@
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@supabase/supabase-js": "^2.39.8",
"@supabase/supabase-js": "^2.40.0",
"lucide-react": "^0.363.0",
"zod": "^3.22.4"
},

View File

@@ -1,4 +1,4 @@
# Emails - @kit/emails
# Emails - @kit/email-templates
This package is responsible for managing email templates using the react.email library.

View File

@@ -1,5 +1,5 @@
{
"name": "@kit/emails",
"name": "@kit/email-templates",
"private": true,
"version": "0.1.0",
"scripts": {

View File

@@ -16,6 +16,8 @@
},
"devDependencies": {
"@kit/billing-gateway": "*",
"@kit/email-templates": "*",
"@kit/mailers": "*",
"@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0",
"@kit/shared": "*",
@@ -23,6 +25,7 @@
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-icons": "^1.3.0",
"lucide-react": "^0.363.0",
"react-hook-form": "^7.51.2",

View File

@@ -1,17 +1,23 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { ErrorBoundary } from '@kit/ui/error-boundary';
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
@@ -20,10 +26,6 @@ import { Trans } from '@kit/ui/trans';
import { deletePersonalAccountAction } from '../../server/personal-accounts-server-actions';
export function AccountDangerZone() {
return <DeleteAccountContainer />;
}
function DeleteAccountContainer() {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
@@ -45,30 +47,39 @@ function DeleteAccountContainer() {
function DeleteAccountModal() {
return (
<Dialog>
<DialogTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account:deleteAccount'} />
</Button>
</DialogTrigger>
</AlertDialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:deleteAccount'} />
</DialogTitle>
</DialogHeader>
</AlertDialogTitle>
</AlertDialogHeader>
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
<DeleteAccountForm />
</ErrorBoundary>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm() {
const form = useForm();
const form = useForm({
resolver: zodResolver(
z.object({
confirmation: z.string().refine((value) => value === 'DELETE'),
}),
),
defaultValues: {
confirmation: '',
},
});
return (
<Form {...form}>
@@ -110,17 +121,25 @@ function DeleteAccountForm() {
</FormItem>
</div>
<div className={'flex justify-end space-x-2.5'}>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton />
</div>
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
type={'submit'}
disabled={pending}
data-test={'confirm-delete-account-button'}
name={'action'}
value={'delete'}

View File

@@ -4,7 +4,7 @@ import type { Factor } from '@supabase/gotrue-js';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
@@ -84,6 +85,8 @@ export function MultiFactorAuthFactorsList() {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'info'}>
<ShieldCheck className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</AlertTitle>
@@ -171,6 +174,10 @@ function ConfirmUnenrollFactorModal(
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogContent>
</AlertDialog>
);

View File

@@ -5,6 +5,8 @@ import { useState } from 'react';
import type { User } from '@supabase/gotrue-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -15,6 +17,7 @@ import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -38,16 +41,6 @@ export const UpdatePasswordForm = ({
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
const form = useForm({
resolver: zodResolver(
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
),
defaultValues: {
newPassword: '',
repeatPassword: '',
},
});
const updatePasswordFromCredential = (password: string) => {
const redirectTo = [window.location.origin, callbackPath].join('');
@@ -87,6 +80,16 @@ export const UpdatePasswordForm = ({
updatePasswordFromCredential(newPassword);
};
const form = useForm({
resolver: zodResolver(
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
),
defaultValues: {
newPassword: '',
repeatPassword: '',
},
});
return (
<Form {...form}>
<form
@@ -95,27 +98,11 @@ export const UpdatePasswordForm = ({
>
<div className={'flex flex-col space-y-4'}>
<If condition={updateUserMutation.data}>
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
<SuccessAlert />
</If>
<If condition={needsReauthentication}>
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
<NeedsReauthenticationAlert />
</If>
<FormField
@@ -164,6 +151,10 @@ export const UpdatePasswordForm = ({
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
@@ -180,3 +171,35 @@ export const UpdatePasswordForm = ({
</Form>
);
};
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
}
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -64,7 +64,6 @@ export function UpdateAccountDetailsForm({
>
<FormField
name={'displayName'}
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>

View File

@@ -2,12 +2,16 @@
import { RedirectType, redirect } from 'next/navigation';
import { z } from 'zod';
import { Logger } from '@kit/shared/logger';
import { requireAuth } from '@kit/supabase/require-auth';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { PersonalAccountsService } from './services/personal-accounts.service';
const emailSettings = getEmailSettingsFromEnvironment();
export async function refreshAuthSession() {
const client = getSupabaseServerActionClient();
@@ -23,40 +27,45 @@ export async function deletePersonalAccountAction(formData: FormData) {
throw new Error('Confirmation required to delete account');
}
const session = await requireAuth(getSupabaseServerActionClient());
const client = getSupabaseServerActionClient();
const session = await requireAuth(client);
if (session.error) {
Logger.error(`User is not authenticated. Redirecting to login page`);
redirect(session.redirectTo);
}
const client = getSupabaseServerActionClient();
const service = new PersonalAccountsService(client);
// retrieve user ID and email
const userId = session.data.user.id;
const userEmail = session.data.user.email ?? null;
Logger.info(
{
userId,
name: 'accounts',
},
`Deleting personal account...`,
);
// create a new instance of the personal accounts service
const service = new PersonalAccountsService(client);
await service.deletePersonalAccount(
getSupabaseServerActionClient({ admin: true }),
{
userId,
},
);
Logger.info(
{
userId,
name: 'accounts',
},
`Personal account deleted successfully.`,
);
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({
adminClient: getSupabaseServerActionClient({ admin: true }),
userId,
userEmail,
emailSettings,
});
// sign out the user after deleting their account
await client.auth.signOut();
// redirect to the home page
redirect('/', RedirectType.replace);
}
function getEmailSettingsFromEnvironment() {
return z
.object({
fromEmail: z.string().email(),
productName: z.string().min(1),
})
.parse({
fromEmail: process.env.EMAIL_SENDER,
productName: process.env.NEXT_PUBLIC_PRODUCT_NAME,
});
}

View File

@@ -1,6 +1,7 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { BillingGatewayService } from '@kit/billing-gateway';
import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -22,17 +23,25 @@ export class PersonalAccountsService {
* Delete personal account of a user.
* This will delete the user from the authentication provider and cancel all subscriptions.
*/
async deletePersonalAccount(
adminClient: SupabaseClient<Database>,
params: { userId: string },
) {
async deletePersonalAccount(params: {
adminClient: SupabaseClient<Database>;
userId: string;
userEmail: string | null;
emailSettings: {
fromEmail: string;
productName: string;
};
}) {
Logger.info(
{ userId: params.userId, name: this.namespace },
'User requested deletion. Processing...',
);
// execute the deletion of the user
try {
await adminClient.auth.admin.deleteUser(params.userId);
await params.adminClient.auth.admin.deleteUser(params.userId);
} catch (error) {
Logger.error(
{
@@ -46,6 +55,7 @@ export class PersonalAccountsService {
throw new Error('Error deleting user');
}
// Cancel all user subscriptions
try {
await this.cancelAllUserSubscriptions(params.userId);
} catch (error) {
@@ -55,6 +65,57 @@ export class PersonalAccountsService {
name: this.namespace,
});
}
// Send account deletion email
if (params.userEmail) {
try {
Logger.info(
{
userId: params.userId,
name: this.namespace,
},
`Sending account deletion email...`,
);
await this.sendAccountDeletionEmail({
fromEmail: params.emailSettings.fromEmail,
productName: params.emailSettings.productName,
userDisplayName: params.userEmail,
userEmail: params.userEmail,
});
} catch (error) {
Logger.error(
{
userId: params.userId,
name: this.namespace,
error,
},
`Error sending account deletion email`,
);
}
}
}
private async sendAccountDeletionEmail(params: {
fromEmail: string;
userEmail: string;
userDisplayName: string;
productName: string;
}) {
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const mailer = new Mailer();
const html = await renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
await mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject: 'Account Deletion Request',
html,
});
}
private async cancelAllUserSubscriptions(userId: string) {
@@ -93,6 +154,7 @@ export class PersonalAccountsService {
);
}
// execute all cancellation requests
await Promise.all(cancellationRequests);
Logger.info(

View File

@@ -1,137 +0,0 @@
'use client';
import { useState } from 'react';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { OtpInput } from '@kit/ui/otp-input';
import { Trans } from '@kit/ui/trans';
export function EmailOtpContainer({
shouldCreateUser,
onSignIn,
inviteCode,
redirectUrl,
}: React.PropsWithChildren<{
inviteCode?: string;
redirectUrl: string;
shouldCreateUser: boolean;
onSignIn?: () => void;
}>) {
const [email, setEmail] = useState('');
if (email) {
return (
<VerifyOtpForm
redirectUrl={redirectUrl}
inviteCode={inviteCode}
onSuccess={onSignIn}
email={email}
/>
);
}
return (
<EmailOtpForm onSuccess={setEmail} shouldCreateUser={shouldCreateUser} />
);
}
function VerifyOtpForm({
email,
inviteCode,
onSuccess,
redirectUrl,
}: {
email: string;
redirectUrl: string;
onSuccess?: () => void;
inviteCode?: string;
}) {
const verifyOtpMutation = useVerifyOtp();
const [verifyCode, setVerifyCode] = useState('');
return (
<form
className={'w-full'}
onSubmit={async (event) => {
event.preventDefault();
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
const redirectTo = [redirectUrl, queryParams].join('');
await verifyOtpMutation.mutateAsync({
email,
token: verifyCode,
type: 'email',
options: {
redirectTo,
},
});
onSuccess && onSuccess();
}}
>
<div className={'flex flex-col space-y-4'}>
<OtpInput onValid={setVerifyCode} onInvalid={() => setVerifyCode('')} />
<Button disabled={verifyOtpMutation.isPending || !verifyCode}>
{verifyOtpMutation.isPending ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:submitVerificationCode'} />
)}
</Button>
</div>
</form>
);
}
function EmailOtpForm({
shouldCreateUser,
onSuccess,
}: React.PropsWithChildren<{
shouldCreateUser: boolean;
onSuccess: (email: string) => void;
}>) {
const signInWithOtpMutation = useSignInWithOtp();
return (
<form
className={'w-full'}
onSubmit={async (event) => {
event.preventDefault();
const email = event.currentTarget.email.value;
await signInWithOtpMutation.mutateAsync({
email,
options: {
shouldCreateUser,
},
});
onSuccess(email);
}}
>
<div className={'flex flex-col space-y-4'}>
<Label>
<Trans i18nKey={'auth:emailAddress'} />
<Input name={'email'} type={'email'} placeholder={''} />
</Label>
<Button disabled={signInWithOtpMutation.isPending}>
<If
condition={signInWithOtpMutation.isPending}
fallback={<Trans i18nKey={'auth:sendEmailCode'} />}
>
<Trans i18nKey={'auth:sendingEmailCode'} />
</If>
</Button>
</div>
</form>
);
}

View File

@@ -13,7 +13,7 @@
},
"devDependencies": {
"@kit/accounts": "*",
"@kit/emails": "*",
"@kit/email-templates": "*",
"@kit/eslint-config": "0.2.0",
"@kit/mailers": "*",
"@kit/prettier-config": "0.1.0",
@@ -22,12 +22,12 @@
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@hookform/resolvers/zod": "1.0.0",
"@hookform/resolvers": "^3.3.4",
"lucide-react": "^0.363.0"
},
"peerDependencies": {
"@kit/accounts": "0.1.0",
"@kit/emails": "0.1.0",
"@kit/email-templates": "0.1.0",
"@kit/mailers": "0.1.0",
"@kit/shared": "0.1.0",
"@kit/supabase": "0.1.0",

View File

@@ -149,7 +149,9 @@ export class AccountInvitationsService {
for (const invitation of responseInvitations) {
const promise = async () => {
try {
const { renderInviteEmail } = await import('@kit/emails');
const { renderInviteEmail } = await import(
'../../../../email-templates'
);
const html = renderInviteEmail({
link: this.getInvitationLink(invitation.invite_token),

View File

@@ -29,13 +29,13 @@
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.39.8",
"@supabase/supabase-js": "^2.40.0",
"@tanstack/react-query": "5.28.6"
},
"peerDependencies": {
"@epic-web/invariant": "^1.0.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.39.8",
"@supabase/supabase-js": "^2.40.0",
"@tanstack/react-query": "^5.28.6"
},
"eslintConfig": {

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
@@ -38,7 +39,7 @@
"class-variance-authority": "^0.7.0",
"date-fns": "^3.2.0",
"lucide-react": "^0.363.0",
"react-hook-form": "^7.49.2",
"react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0",
"sonner": "^1.4.41",
"zod": "^3.22.4"
@@ -58,7 +59,7 @@
"lucide-react": "^0.363.0",
"prettier": "^3.2.4",
"react-day-picker": "^8.10.0",
"react-hook-form": "^7.51.1",
"react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0",
"sonner": "^1.4.41",
"tailwindcss": "3.4.1",

View File

@@ -2,7 +2,7 @@ import type { MDXComponents } from 'mdx/types';
import { getMDXComponent } from 'next-contentlayer/hooks';
import Components from './mdx-components';
// @ts-expect-error
// @ts-ignore
import styles from './mdx-renderer.module.css';
export function Mdx({

View File

@@ -5,7 +5,7 @@ import { Slot } from '@radix-ui/react-slot';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import { cn } from '../utils/cn';
import { cn } from '../utils';
import { Label } from './label';
const Form = FormProvider;

View File

@@ -36,7 +36,13 @@ const InputOTPSlot = React.forwardRef<
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
const slot = inputOTPContext.slots[index];
if (!slot) {
return null;
}
const { char, isActive, hasFakeCaret } = slot;
return (
<div