B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { ErrorBoundary } from '@kit/monitoring/components';
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Form } from '@kit/ui/form';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
|
||||
import { deletePersonalAccountAction } from '../../server/personal-accounts-server-actions';
|
||||
|
||||
export function AccountDangerZone() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm font-medium'}>
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</span>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'account:deleteAccountDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DeleteAccountModal />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountModal() {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button data-test={'delete-account-button'} variant={'destructive'}>
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
|
||||
<DeleteAccountForm email={user.email} />
|
||||
</ErrorBoundary>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountForm(props: { email: string }) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeletePersonalAccountSchema),
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
if (!otp) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose={'delete-personal-account'}
|
||||
email={props.email}
|
||||
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'delete-account-form'}
|
||||
action={deletePersonalAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={
|
||||
'border-destructive text-destructive rounded-md border p-4 text-sm'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
<Trans i18nKey={'account:deleteAccountDescription'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
type={'submit'}
|
||||
disabled={pending || props.disabled}
|
||||
name={'action'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey={'account:deletingAccount'} />
|
||||
) : (
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorContainer() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<DeleteAccountErrorAlert />
|
||||
|
||||
<div>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
|
||||
import { AccountDangerZone } from './account-danger-zone';
|
||||
import { UpdateEmailFormContainer } from './email/update-email-form-container';
|
||||
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
|
||||
import { UpdatePasswordFormContainer } from './password/update-password-container';
|
||||
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
|
||||
import { UpdateAccountImageContainer } from './update-account-image-container';
|
||||
|
||||
export function PersonalAccountSettingsContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
|
||||
features: {
|
||||
enableAccountDeletion: boolean;
|
||||
enablePasswordUpdate: boolean;
|
||||
};
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const supportsLanguageSelection = useSupportMultiLanguage();
|
||||
const user = usePersonalAccountData(props.userId);
|
||||
|
||||
if (!user.data || user.isPending) {
|
||||
return <LoadingOverlay fullPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-4 pb-32'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:accountImage'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:accountImageDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountImageContainer
|
||||
user={{
|
||||
pictureUrl: user.data.picture_url,
|
||||
id: user.data.id,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:name'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:nameDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountDetailsFormContainer user={user.data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={supportsLanguageSelection}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:language'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:languageDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<LanguageSelector />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:updateEmailCardTitle'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:updateEmailCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={props.features.enablePasswordUpdate}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:updatePasswordCardTitle'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:updatePasswordCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuth'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<MultiFactorAuthFactorsList userId={props.userId} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={props.features.enableAccountDeletion}>
|
||||
<Card className={'border-destructive'}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account:dangerZone'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account:dangerZoneDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountDangerZone />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSupportMultiLanguage() {
|
||||
const { i18n } = useTranslation();
|
||||
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
|
||||
|
||||
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
|
||||
|
||||
return supportedLangs.length > 1;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
import { UpdateEmailForm } from './update-email-form';
|
||||
|
||||
export function UpdateEmailFormContainer(props: { callbackPath: string }) {
|
||||
const { data: user, isPending } = useUser();
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UpdateEmailForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdateEmailSchema } from '../../../schema/update-email.schema';
|
||||
|
||||
function createEmailResolver(currentEmail: string, errorMessage: string) {
|
||||
return zodResolver(
|
||||
UpdateEmailSchema.withTranslation(errorMessage).refine((schema) => {
|
||||
return schema.email !== currentEmail;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateEmailForm({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) {
|
||||
const { t } = useTranslation('account');
|
||||
const updateUserMutation = useUpdateUser();
|
||||
|
||||
const updateEmail = ({ email }: { email: string }) => {
|
||||
// then, we update the user's email address
|
||||
const promise = async () => {
|
||||
const redirectTo = new URL(
|
||||
callbackPath,
|
||||
window.location.origin,
|
||||
).toString();
|
||||
|
||||
await updateUserMutation.mutateAsync({ email, redirectTo });
|
||||
};
|
||||
|
||||
toast.promise(promise, {
|
||||
success: t(`updateEmailSuccess`),
|
||||
loading: t(`updateEmailLoading`),
|
||||
error: t(`updateEmailError`),
|
||||
});
|
||||
};
|
||||
|
||||
const currentEmail = user.email;
|
||||
|
||||
const form = useForm({
|
||||
resolver: createEmailResolver(currentEmail!, t('emailNotMatching')),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
repeatEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
data-test={'account-email-form'}
|
||||
onSubmit={form.handleSubmit(updateEmail)}
|
||||
>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:updateEmailSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:updateEmailSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:newEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-email-form-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:repeatEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
<Trans i18nKey={'account:updateEmailSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account-settings-container';
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { Factor } from '@supabase/supabase-js';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
|
||||
|
||||
export function MultiFactorAuthFactorsList(props: { userId: string }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FactorsTableContainer userId={props.userId} />
|
||||
|
||||
<div>
|
||||
<MultiFactorAuthSetupDialog userId={props.userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorsTableContainer(props: { userId: string }) {
|
||||
const {
|
||||
data: factors,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useFetchAuthFactors(props.userId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'account:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:factorsListError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:factorsListErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allFactors = factors?.all ?? [];
|
||||
|
||||
if (!allFactors.length) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert>
|
||||
<ShieldCheck className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <FactorsTable factors={allFactors} userId={props.userId} />;
|
||||
}
|
||||
|
||||
function ConfirmUnenrollFactorModal(
|
||||
props: React.PropsWithChildren<{
|
||||
factorId: string;
|
||||
userId: string;
|
||||
setIsModalOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const unEnroll = useUnenrollFactor(props.userId);
|
||||
|
||||
const onUnenrollRequested = useCallback(
|
||||
(factorId: string) => {
|
||||
if (unEnroll.isPending) return;
|
||||
|
||||
const promise = unEnroll.mutateAsync(factorId).then((response) => {
|
||||
props.setIsModalOpen(false);
|
||||
|
||||
if (!response.success) {
|
||||
const errorCode = response.data;
|
||||
|
||||
throw t(`auth:errors.${errorCode}`, {
|
||||
defaultValue: t(`account:unenrollFactorError`),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t(`account:unenrollingFactor`),
|
||||
success: t(`account:unenrollFactorSuccess`),
|
||||
error: (error: string) => {
|
||||
return error;
|
||||
},
|
||||
});
|
||||
},
|
||||
[props, t, unEnroll],
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertDialog open={!!props.factorId} onOpenChange={props.setIsModalOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:unenrollFactorModalHeading'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans i18nKey={'account:unenrollFactorModalDescription'} />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
type={'button'}
|
||||
disabled={unEnroll.isPending}
|
||||
onClick={() => onUnenrollRequested(props.factorId)}
|
||||
>
|
||||
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorsTable({
|
||||
factors,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
factors: Factor[];
|
||||
userId: string;
|
||||
}>) {
|
||||
const [unEnrolling, setUnenrolling] = useState<string>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account:factorName'} />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account:factorType'} />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account:factorStatus'} />
|
||||
</TableHead>
|
||||
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{factors.map((factor) => (
|
||||
<TableRow key={factor.id}>
|
||||
<TableCell>
|
||||
<span className={'block truncate'}>{factor.friendly_name}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant={'info'} className={'inline-flex uppercase'}>
|
||||
{factor.factor_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<td>
|
||||
<Badge
|
||||
className={'inline-flex capitalize'}
|
||||
variant={factor.status === 'verified' ? 'success' : 'outline'}
|
||||
>
|
||||
{factor.status}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
<td className={'flex justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={() => setUnenrolling(factor.id)}
|
||||
>
|
||||
<X className={'h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans i18nKey={'account:unenrollTooltip'} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<If condition={unEnrolling}>
|
||||
{(factorId) => (
|
||||
<ConfirmUnenrollFactorModal
|
||||
userId={userId}
|
||||
factorId={factorId}
|
||||
setIsModalOpen={() => setUnenrolling(undefined)}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useUnenrollFactor(userId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSupabase();
|
||||
const mutationKey = useFactorsMutationKey(userId);
|
||||
|
||||
const mutationFn = async (factorId: string) => {
|
||||
const { data, error } = await client.auth.mfa.unenroll({
|
||||
factorId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false as const,
|
||||
data: error.code as string,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
onSuccess: () => {
|
||||
return queryClient.refetchQueries({
|
||||
queryKey: mutationKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@kit/ui/input-otp';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
|
||||
|
||||
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const onEnrollSuccess = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
|
||||
return toast.success(t(`account:multiFactorSetupSuccess`));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Trans i18nKey={'account:setupMfaButtonLabel'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'account:setupMfaButtonLabel'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<MultiFactorAuthSetupForm
|
||||
userId={props.userId}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onEnrolled={onEnrollSuccess}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiFactorAuthSetupForm({
|
||||
onEnrolled,
|
||||
onCancel,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
onCancel: () => void;
|
||||
onEnrolled: () => void;
|
||||
}>) {
|
||||
const verifyCodeMutation = useVerifyCodeMutation(userId);
|
||||
|
||||
const verificationCodeForm = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
factorId: z.string().min(1),
|
||||
verificationCode: z.string().min(6).max(6),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
factorId: '',
|
||||
verificationCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [state, setState] = useState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
const factorId = useWatch({
|
||||
name: 'factorId',
|
||||
control: verificationCodeForm.control,
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({
|
||||
verificationCode,
|
||||
factorId,
|
||||
}: {
|
||||
verificationCode: string;
|
||||
factorId: string;
|
||||
}) => {
|
||||
setState({
|
||||
loading: true,
|
||||
error: '',
|
||||
});
|
||||
|
||||
try {
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
factorId,
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
await refreshAuthSession();
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
onEnrolled();
|
||||
} catch (error) {
|
||||
const message = (error as Error).message || `Unknown error`;
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onEnrolled, verifyCodeMutation],
|
||||
);
|
||||
|
||||
if (state.error) {
|
||||
return <ErrorAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<FactorQrCode
|
||||
userId={userId}
|
||||
onCancel={onCancel}
|
||||
onSetFactorId={(factorId) =>
|
||||
verificationCodeForm.setValue('factorId', factorId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<If condition={factorId}>
|
||||
<Form {...verificationCodeForm}>
|
||||
<form
|
||||
onSubmit={verificationCodeForm.handleSubmit(onSubmit)}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<FormField
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem
|
||||
className={
|
||||
'mx-auto flex flex-col items-center justify-center'
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<InputOTP {...field} maxLength={6} minLength={6}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans
|
||||
i18nKey={'account:verifyActivationCodeDescription'}
|
||||
/>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
name={'verificationCode'}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
!verificationCodeForm.formState.isValid || state.loading
|
||||
}
|
||||
type={'submit'}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Trans i18nKey={'account:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'account:enableMfaFactor'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorQrCode({
|
||||
onSetFactorId,
|
||||
onCancel,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
onCancel: () => void;
|
||||
onSetFactorId: (factorId: string) => void;
|
||||
}>) {
|
||||
const enrollFactorMutation = useEnrollFactor(userId);
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
factorName: z.string().min(1),
|
||||
qrCode: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
factorName: '',
|
||||
qrCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const factorName = useWatch({ name: 'factorName', control: form.control });
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-2'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:qrCodeErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={`auth:errors.${error}`}
|
||||
defaults={t('account:qrCodeErrorDescription')}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Button variant={'outline'} onClick={onCancel}>
|
||||
<ArrowLeftIcon className={'h-4'} />
|
||||
<Trans i18nKey={`common:retry`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!factorName) {
|
||||
return (
|
||||
<FactorNameForm
|
||||
onCancel={onCancel}
|
||||
onSetFactorName={async (name) => {
|
||||
const response = await enrollFactorMutation.mutateAsync(name);
|
||||
|
||||
if (!response.success) {
|
||||
return setError(response.data as string);
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.type === 'totp') {
|
||||
form.setValue('factorName', name);
|
||||
form.setValue('qrCode', data.totp.qr_code);
|
||||
}
|
||||
|
||||
// dispatch event to set factor ID
|
||||
onSetFactorId(data.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'dark:bg-secondary flex flex-col space-y-4 rounded-lg border p-4'
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<span className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'account:multiFactorModalHeading'} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<QrImage src={form.getValues('qrCode')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorNameForm(
|
||||
props: React.PropsWithChildren<{
|
||||
onSetFactorName: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}>,
|
||||
) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
props.onSetFactorName(data.name);
|
||||
})}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:factorNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input autoComplete={'off'} required {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:factorNameHint'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button type={'submit'}>
|
||||
<Trans i18nKey={'account:factorNameSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function QrImage({ src }: { src: string }) {
|
||||
return (
|
||||
<img
|
||||
alt={'QR Code'}
|
||||
src={src}
|
||||
width={160}
|
||||
height={160}
|
||||
className={'bg-white p-2'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnrollFactor(userId: string) {
|
||||
const client = useSupabase();
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useFactorsMutationKey(userId);
|
||||
|
||||
const mutationFn = async (factorName: string) => {
|
||||
const response = await client.auth.mfa.enroll({
|
||||
friendlyName: factorName,
|
||||
factorType: 'totp',
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
success: false as const,
|
||||
data: response.error.code,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
onSuccess() {
|
||||
return queryClient.refetchQueries({
|
||||
queryKey: mutationKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useVerifyCodeMutation(userId: string) {
|
||||
const mutationKey = useFactorsMutationKey(userId);
|
||||
const client = useSupabase();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutationFn = async (params: { factorId: string; code: string }) => {
|
||||
const challenge = await client.auth.mfa.challenge({
|
||||
factorId: params.factorId,
|
||||
});
|
||||
|
||||
if (challenge.error) {
|
||||
throw challenge.error;
|
||||
}
|
||||
|
||||
const challengeId = challenge.data.id;
|
||||
|
||||
const verify = await client.auth.mfa.verify({
|
||||
factorId: params.factorId,
|
||||
code: params.code,
|
||||
challengeId,
|
||||
});
|
||||
|
||||
if (verify.error) {
|
||||
throw verify.error;
|
||||
}
|
||||
|
||||
return verify;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
return queryClient.refetchQueries({ queryKey: mutationKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert } from '@kit/ui/alert';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdatePasswordForm } from './update-password-form';
|
||||
|
||||
export function UpdatePasswordFormContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
callbackPath: string;
|
||||
}>,
|
||||
) {
|
||||
const { data: user, isPending } = useUser();
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUpdatePassword = user.identities?.some(
|
||||
(item) => item.provider === `email`,
|
||||
);
|
||||
|
||||
if (!canUpdatePassword) {
|
||||
return <WarnCannotUpdatePasswordAlert />;
|
||||
}
|
||||
|
||||
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
|
||||
function WarnCannotUpdatePasswordAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<Trans i18nKey={'account:cannotUpdatePassword'} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { User } from '@supabase/supabase-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';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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 { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
|
||||
|
||||
export const UpdatePasswordForm = ({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) => {
|
||||
const { t } = useTranslation('account');
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const [needsReauthentication, setNeedsReauthentication] = useState(false);
|
||||
|
||||
const updatePasswordFromCredential = (password: string) => {
|
||||
const redirectTo = [window.location.origin, callbackPath].join('');
|
||||
|
||||
const promise = updateUserMutation
|
||||
.mutateAsync({ password, redirectTo })
|
||||
.catch((error) => {
|
||||
if (
|
||||
typeof error === 'string' &&
|
||||
error?.includes('Password update requires reauthentication')
|
||||
) {
|
||||
setNeedsReauthentication(true);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
toast.promise(() => promise, {
|
||||
success: t(`updatePasswordSuccess`),
|
||||
error: t(`updatePasswordError`),
|
||||
loading: t(`updatePasswordLoading`),
|
||||
});
|
||||
};
|
||||
|
||||
const updatePasswordCallback = async ({
|
||||
newPassword,
|
||||
}: {
|
||||
newPassword: string;
|
||||
}) => {
|
||||
const email = user.email;
|
||||
|
||||
// if the user does not have an email assigned, it's possible they
|
||||
// don't have an email/password factor linked, and the UI is out of sync
|
||||
if (!email) {
|
||||
return Promise.reject(t(`cannotUpdatePassword`));
|
||||
}
|
||||
|
||||
updatePasswordFromCredential(newPassword);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
|
||||
),
|
||||
defaultValues: {
|
||||
newPassword: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'account-password-form'}
|
||||
onSubmit={form.handleSubmit(updatePasswordCallback)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<SuccessAlert />
|
||||
</If>
|
||||
|
||||
<If condition={needsReauthentication}>
|
||||
<NeedsReauthenticationAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:newPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-password-input'}
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:repeatPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-repeat-password-input'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
|
||||
import { UpdateAccountDetailsForm } from './update-account-details-form';
|
||||
|
||||
export function UpdateAccountDetailsFormContainer({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string | null;
|
||||
id: string;
|
||||
};
|
||||
}) {
|
||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
return (
|
||||
<UpdateAccountDetailsForm
|
||||
displayName={user.name ?? ''}
|
||||
userId={user.id}
|
||||
onUpdate={() => revalidateUserDataQuery(user.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useUpdateAccountData } from '../../hooks/use-update-account';
|
||||
import { AccountDetailsSchema } from '../../schema/account-details.schema';
|
||||
|
||||
type UpdateUserDataParams = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
export function UpdateAccountDetailsForm({
|
||||
displayName,
|
||||
onUpdate,
|
||||
userId,
|
||||
}: {
|
||||
displayName: string;
|
||||
userId: string;
|
||||
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
|
||||
}) {
|
||||
const updateAccountMutation = useUpdateAccountData(userId);
|
||||
const { t } = useTranslation('account');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AccountDetailsSchema),
|
||||
defaultValues: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ displayName }: { displayName: string }) => {
|
||||
const data = { name: displayName };
|
||||
|
||||
const promise = updateAccountMutation.mutateAsync(data).then(() => {
|
||||
onUpdate(data);
|
||||
});
|
||||
|
||||
return toast.promise(() => promise, {
|
||||
success: t(`updateProfileSuccess`),
|
||||
error: t(`updateProfileError`),
|
||||
loading: t(`updateProfileLoading`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-account-name-form'}
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name={'displayName'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:name'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={''}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateAccountMutation.isPending}>
|
||||
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { ImageUploader } from '@kit/ui/image-uploader';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
|
||||
|
||||
const AVATARS_BUCKET = 'account_image';
|
||||
|
||||
export function UpdateAccountImageContainer({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
pictureUrl: string | null;
|
||||
id: string;
|
||||
};
|
||||
}) {
|
||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
return (
|
||||
<UploadProfileAvatarForm
|
||||
pictureUrl={user.pictureUrl ?? null}
|
||||
userId={user.id}
|
||||
onAvatarUpdated={() => revalidateUserDataQuery(user.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProfileAvatarForm(props: {
|
||||
pictureUrl: string | null;
|
||||
userId: string;
|
||||
onAvatarUpdated: () => void;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const { t } = useTranslation('account');
|
||||
|
||||
const createToaster = useCallback(
|
||||
(promise: () => Promise<unknown>) => {
|
||||
return toast.promise(promise, {
|
||||
success: t(`updateProfileSuccess`),
|
||||
error: t(`updateProfileError`),
|
||||
loading: t(`updateProfileLoading`),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(file: File | null) => {
|
||||
const removeExistingStorageFile = () => {
|
||||
if (props.pictureUrl) {
|
||||
return (
|
||||
deleteProfilePhoto(client, props.pictureUrl) ?? Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
if (file) {
|
||||
const promise = () =>
|
||||
removeExistingStorageFile().then(() =>
|
||||
uploadUserProfilePhoto(client, file, props.userId)
|
||||
.then((pictureUrl) => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: pictureUrl,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
}),
|
||||
);
|
||||
|
||||
createToaster(promise);
|
||||
} else {
|
||||
const promise = () =>
|
||||
removeExistingStorageFile()
|
||||
.then(() => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: null,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
});
|
||||
|
||||
createToaster(promise);
|
||||
}
|
||||
},
|
||||
[client, createToaster, props],
|
||||
);
|
||||
|
||||
return (
|
||||
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'account:profilePictureHeading'} />
|
||||
</span>
|
||||
|
||||
<span className={'text-xs'}>
|
||||
<Trans i18nKey={'account:profilePictureSubheading'} />
|
||||
</span>
|
||||
</div>
|
||||
</ImageUploader>
|
||||
);
|
||||
}
|
||||
|
||||
function deleteProfilePhoto(client: SupabaseClient<Database>, url: string) {
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const fileName = url.split('/').pop()?.split('?')[0];
|
||||
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
return bucket.remove([fileName]);
|
||||
}
|
||||
|
||||
async function uploadUserProfilePhoto(
|
||||
client: SupabaseClient<Database>,
|
||||
photoFile: File,
|
||||
userId: string,
|
||||
) {
|
||||
const bytes = await photoFile.arrayBuffer();
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const extension = photoFile.name.split('.').pop();
|
||||
const fileName = await getAvatarFileName(userId, extension);
|
||||
|
||||
const result = await bucket.upload(fileName, bytes);
|
||||
|
||||
if (!result.error) {
|
||||
return bucket.getPublicUrl(fileName).data.publicUrl;
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
async function getAvatarFileName(
|
||||
userId: string,
|
||||
extension: string | undefined,
|
||||
) {
|
||||
const { nanoid } = await import('nanoid');
|
||||
|
||||
// we add a version to the URL to ensure
|
||||
// the browser always fetches the latest image
|
||||
const uniqueId = nanoid(16);
|
||||
|
||||
return `${userId}.${extension}?v=${uniqueId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user