Remove multiple components related to multi-factor authentication setup

Removed personal account related multi-factor authentication setup modal and otp-input. Adjusted dependencies, exports, and imports to reflect the deletion. Various adjustments in other areas of the codebase were made to account for these deletions, including moving necessary components and adding the 'input-otp' library in the package.json under 'ui' directory.
This commit is contained in:
giancarlo
2024-03-28 01:30:43 +08:00
parent 500fea4bf8
commit 6048cc4759
21 changed files with 1078 additions and 630 deletions

View File

@@ -128,9 +128,10 @@ function getPatterns() {
handler: async (req: NextRequest, res: NextResponse) => {
const supabase = createMiddlewareClient(req, res);
const { data } = await supabase.auth.getSession();
const isVerifyMfa = req.nextUrl.pathname === pathsConfig.auth.verifyMfa;
// If user is logged in, redirect to home page.
if (data.session) {
if (data.session && !isVerifyMfa) {
return NextResponse.redirect(
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
);
@@ -138,7 +139,7 @@ function getPatterns() {
},
},
{
pattern: new URLPattern({ pathname: '/home/*' }),
pattern: new URLPattern({ pathname: '/home*' }),
handler: async (req: NextRequest, res: NextResponse) => {
const supabase = createMiddlewareClient(req, res);
const { data, error } = await supabase.auth.getSession();
@@ -156,6 +157,10 @@ function getPatterns() {
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
console.log({
requiresMultiFactorAuthentication,
});
// If user requires multi-factor authentication, redirect to MFA page.
if (requiresMultiFactorAuthentication) {
return NextResponse.redirect(

View File

@@ -41,7 +41,7 @@
"edge-csrf": "^1.0.9",
"i18next": "^23.10.1",
"i18next-resources-to-backend": "^1.2.0",
"next": "canary",
"next": "14.2.0-canary.44",
"next-contentlayer": "0.3.4",
"next-sitemap": "^4.2.3",
"next-themes": "^0.2.1",
@@ -61,7 +61,7 @@
"@kit/prettier-config": "^0.1.0",
"@kit/tailwind-config": "^0.1.0",
"@kit/tsconfig": "^0.1.0",
"@next/bundle-analyzer": "canary",
"@next/bundle-analyzer": "14.2.0-canary.44",
"@types/mdx": "^2.0.10",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",

View File

@@ -10,7 +10,7 @@
"connectedAccounts": "Connected Accounts",
"authenticationTab": "Authentication",
"multiFactorAuth": "Multi-Factor Authentication",
"multiFactorAuthSubheading": "Set up a MFA method to secure your account",
"multiFactorAuthDescription": "Set up Multi-Factor Authentication method to further secure your account",
"connectedAccountsSubheading": "Below are the accounts linked to your profile",
"availableProviders": "Available Providers",
"availableProvidersSubheading": "Click on the providers below to link your profile to the provider",
@@ -66,10 +66,10 @@
"connectWithProvider": "Connect with {{ provider }}",
"connectedWithProvider": "Connected with {{ provider }}",
"setupMfaButtonLabel": "Setup a new Factor",
"multiFactorSetupError": "Sorry, there was an error while setting up your factor. Please try again.",
"multiFactorSetupErrorHeading": "Setup Failed",
"multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.",
"multiFactorAuthHeading": "Secure your account with Multi-Factor Authentication",
"multiFactorAuthDescription": "Enable Multi-Factor Authentication to verify your identity for an extra layer of security to your account in case your password is stolen. In addition to entering your password, it requires you confirm your identity via SMS.",
"multiFactorModalHeading": "Use your phone to scan the QR code below. Then enter the code generated.",
"multiFactorModalHeading": "Use your authenticator app to scan the QR code below. Then enter the code generated.",
"factorNameLabel": "A memorable name to identify this factor",
"factorNameHint": "Use an easy-to-remember name to easily identify this factor in the future. Ex. iPhone 14",
"factorNameSubmitLabel": "Set factor name",
@@ -78,6 +78,7 @@
"unenrollFactorSuccess": "Factor successfully unenrolled",
"unenrollFactorError": "Unenrolling factor failed",
"factorsListError": "Error loading factors list",
"factorsListErrorDescription": "Sorry, we couldn't load the factors list. Please try again.",
"factorName": "Factor Name",
"factorType": "Type",
"factorStatus": "Status",
@@ -99,13 +100,16 @@
"loadingFactors": "Loading factors...",
"enableMfaFactor": "Enable Factor",
"disableMfaFactor": "Disable Factor",
"qrCodeError": "Sorry, we weren't able to generate the QR code",
"qrCodeErrorHeading": "QR Code Error",
"qrCodeErrorDescription": "Sorry, we weren't able to generate the QR code",
"multiFactorSetupSuccess": "Factor successfully enrolled",
"submitVerificationCode": "Submit Verification Code",
"mfaEnabledSuccessAlert": "Multi-Factor authentication is enabled",
"verifyingCode": "Verifying code...",
"invalidVerificationCode": "Invalid verification code. Please try again",
"invalidVerificationCodeHeading": "Invalid Verification Code",
"invalidVerificationCodeDescription": "The verification code you entered is invalid. Please try again.",
"unenrollFactorModalHeading": "Unenroll Factor",
"unenrollFactorModalDescription": "You're about to unenroll this factor. You will not be able to use it to login to your account.",
"unenrollFactorModalBody": "You're about to unenroll this factor. You will not be able to use it to login to your account.",
"unenrollFactorModalButtonLabel": "Yes, unenroll factor",
"selectFactor": "Choose a factor to verify your identity",

View File

@@ -24,7 +24,9 @@
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@radix-ui/react-icons": "^1.3.0",
"lucide-react": "^0.363.0"
"lucide-react": "^0.363.0",
"react-hook-form": "^7.51.2",
"zod": "^3.22.4"
},
"peerDependencies": {
"@kit/shared": "0.1.0",

View File

@@ -1,3 +1,5 @@
'use client';
import {
Card,
CardContent,
@@ -9,10 +11,11 @@ import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
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';
import { UpdateEmailFormContainer } from './update-email-form-container';
import { UpdatePasswordFormContainer } from './update-password-container';
export function PersonalAccountSettingsContainer(
props: React.PropsWithChildren<{
@@ -91,6 +94,22 @@ export function PersonalAccountSettingsContainer(
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<MultiFactorAuthFactorsList />
</CardContent>
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive border-2'}>
<CardHeader>

View File

@@ -22,7 +22,7 @@ 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';
import { UpdateEmailSchema } from '../../../schema/update-email.schema';
function createEmailResolver(currentEmail: string, errorMessage: string) {
return zodResolver(

View File

@@ -0,0 +1,279 @@
import { useCallback, useState } from 'react';
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 { 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,
AlertDialogContent,
AlertDialogDescription,
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';
const MAX_FACTOR_COUNT = 10;
export function MultiFactorAuthFactorsList() {
const { data: factors, isLoading, isError } = useFetchAuthFactors();
const [unEnrolling, setUnenrolling] = useState<string>();
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 variant={'info'}>
<AlertTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</AlertDescription>
</Alert>
<div>
<MultiFactorAuthSetupDialog />
</div>
</div>
);
}
const canAddNewFactors = allFactors.length < MAX_FACTOR_COUNT;
return (
<div className={'flex flex-col space-y-4'}>
<FactorsTable factors={allFactors} setUnenrolling={setUnenrolling} />
<If condition={canAddNewFactors}>
<div>
<MultiFactorAuthSetupDialog />
</div>
</If>
<If condition={unEnrolling}>
{(factorId) => (
<ConfirmUnenrollFactorModal
factorId={factorId}
setIsModalOpen={() => setUnenrolling(undefined)}
/>
)}
</If>
</div>
);
}
function ConfirmUnenrollFactorModal(
props: React.PropsWithChildren<{
factorId: string;
setIsModalOpen: (isOpen: boolean) => void;
}>,
) {
const { t } = useTranslation();
const unEnroll = useUnenrollFactor();
const onUnenrollRequested = useCallback(
(factorId: string) => {
if (unEnroll.isPending) return;
const promise = unEnroll.mutateAsync(factorId).then(() => {
props.setIsModalOpen(false);
});
toast.promise(promise, {
loading: t(`account:unenrollingFactor`),
success: t(`account:unenrollFactorSuccess`),
error: t(`account:unenrollFactorError`),
});
},
[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>
<AlertDialogAction
className={'w-full'}
type={'button'}
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
);
}
function FactorsTable({
setUnenrolling,
factors,
}: React.PropsWithChildren<{
setUnenrolling: (factorId: string) => void;
factors: Factor[];
}>) {
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
variant={'info'}
className={'inline-flex capitalize'}
color={factor.status === 'verified' ? 'success' : 'normal'}
>
{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>
);
}
function useUnenrollFactor() {
const queryClient = useQueryClient();
const client = useSupabase();
const mutationKey = useFactorsMutationKey();
const mutationFn = async (factorId: string) => {
const { data, error } = await client.auth.mfa.unenroll({
factorId,
});
if (error) {
throw error;
}
return data;
};
return useMutation({
mutationFn,
mutationKey,
onSuccess: async () => {
return queryClient.refetchQueries({
queryKey: mutationKey,
});
},
});
}

View File

@@ -0,0 +1,450 @@
'use client';
import { useCallback, useState } from 'react';
import Image from 'next/image';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation } from '@tanstack/react-query';
import { useForm } 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,
} 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() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const onEnrollSuccess = useCallback(() => {
setIsOpen(false);
return toast.success(t(`multiFactorSetupSuccess`));
}, [t]);
return (
<>
<Button onClick={() => setIsOpen(true)}>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<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
onCancel={() => setIsOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
function MultiFactorAuthSetupForm({
onEnrolled,
onCancel,
}: React.PropsWithChildren<{
onCancel: () => void;
onEnrolled: () => void;
}>) {
const verifyCodeMutation = useVerifyCodeMutation();
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 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
onCancel={onCancel}
onSetFactorId={(factorId) =>
verificationCodeForm.setValue('factorId', factorId)
}
/>
</div>
<If condition={verificationCodeForm.watch('factorId')}>
<Form {...verificationCodeForm}>
<form
onSubmit={verificationCodeForm.handleSubmit(onSubmit)}
className={'w-full'}
>
<div className={'flex flex-col space-y-4'}>
<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'}
/>
<Button
disabled={!verificationCodeForm.formState.isValid}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
</div>
</form>
</Form>
</If>
</div>
);
}
function FactorQrCode({
onSetFactorId,
onCancel,
}: React.PropsWithChildren<{
onCancel: () => void;
onSetFactorId: (factorId: string) => void;
}>) {
const enrollFactorMutation = useEnrollFactor();
const [error, setError] = useState(false);
const form = useForm({
resolver: zodResolver(
z.object({
factorName: z.string().min(1),
qrCode: z.string().min(1),
}),
),
defaultValues: {
factorName: '',
qrCode: '',
},
});
const factorName = form.watch('factorName');
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={'account:qrCodeErrorDescription'} />
</AlertDescription>
</Alert>
</div>
);
}
if (!factorName) {
return (
<FactorNameForm
onCancel={onCancel}
onSetFactorName={async (name) => {
const data = await enrollFactorMutation.mutateAsync(name);
if (!data) {
return setError(true);
}
form.setValue('factorName', name);
form.setValue('qrCode', data.totp.qr_code);
// dispatch event to set factor ID
onSetFactorId(data.id);
}}
/>
);
}
return (
<div className={'flex flex-col space-y-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>
);
}}
/>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
</Button>
</div>
</form>
</Form>
);
}
function QrImage({ src }: { src: string }) {
return <Image alt={'QR Code'} src={src} width={160} height={160} />;
}
function useEnrollFactor() {
const client = useSupabase();
const mutationKey = useFactorsMutationKey();
const mutationFn = async (factorName: string) => {
const { data, error } = await client.auth.mfa.enroll({
friendlyName: factorName,
factorType: 'totp',
});
if (error) {
throw error;
}
return data;
};
return useMutation({
mutationFn,
mutationKey,
});
}
function useVerifyCodeMutation() {
const mutationKey = useFactorsMutationKey();
const client = useSupabase();
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 });
}
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -1,344 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import Image from 'next/image';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
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';
function MultiFactorAuthSetupModal(
props: React.PropsWithChildren<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}>,
) {
const { t } = useTranslation();
const onEnrollSuccess = useCallback(() => {
props.setIsOpen(false);
return toast.success(t(`multiFactorSetupSuccess`));
}, [props, t]);
return (
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</DialogTitle>
</DialogHeader>
<MultiFactorAuthSetupForm
onCancel={() => props.setIsOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</DialogContent>
</Dialog>
);
}
function MultiFactorAuthSetupForm({
onEnrolled,
onCancel,
}: React.PropsWithChildren<{
onCancel: () => void;
onEnrolled: () => void;
}>) {
const verifyCodeMutation = useVerifyCodeMutation();
const [factorId, setFactorId] = useState<string | undefined>();
const [verificationCode, setVerificationCode] = useState('');
const [state, setState] = useState({
loading: false,
error: '',
});
const onSubmit = useCallback(async () => {
setState({
loading: true,
error: '',
});
if (!factorId || !verificationCode) {
return setState({
loading: false,
error: 'No factor ID or verification code found',
});
}
try {
await verifyCodeMutation.mutateAsync({
factorId,
code: verificationCode,
});
setState({
loading: false,
error: '',
});
onEnrolled();
} catch (error) {
const message = (error as Error).message || `Unknown error`;
setState({
loading: false,
error: message,
});
}
}, [onEnrolled, verifyCodeMutation, factorId, verificationCode]);
if (state.error) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<Trans i18nKey={'account:multiFactorSetupError'} />
</Alert>
</div>
);
}
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex justify-center'}>
<FactorQrCode onCancel={onCancel} onSetFactorId={setFactorId} />
</div>
<If condition={factorId}>
<form
onSubmit={(e) => {
e.preventDefault();
return onSubmit();
}}
className={'w-full'}
>
<div className={'flex flex-col space-y-4'}>
<Label>
<Trans i18nKey={'account:verificationCode'} />
<OtpInput
onInvalid={() => setVerificationCode('')}
onValid={setVerificationCode}
/>
<span>
<Trans i18nKey={'account:verifyActivationCodeDescription'} />
</span>
</Label>
<div className={'flex justify-end space-x-2'}>
<Button disabled={!verificationCode} type={'submit'}>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
)}
</Button>
</div>
</div>
</form>
</If>
</div>
);
}
function FactorQrCode({
onSetFactorId,
onCancel,
}: React.PropsWithChildren<{
onCancel: () => void;
onSetFactorId: React.Dispatch<React.SetStateAction<string | undefined>>;
}>) {
const enrollFactorMutation = useEnrollFactor();
const [error, setError] = useState(false);
const [factor, setFactor] = useState({
name: '',
qrCode: '',
});
const factorName = factor.name;
useEffect(() => {
if (!factorName) {
return;
}
void (async () => {
try {
const data = await enrollFactorMutation.mutateAsync(factorName);
if (!data) {
return setError(true);
}
// set image
setFactor((factor) => {
return {
...factor,
qrCode: data.totp.qr_code,
};
});
// dispatch event to set factor ID
onSetFactorId(data.id);
} catch (e) {
setError(true);
}
})();
}, [onSetFactorId, factorName, enrollFactorMutation]);
if (error) {
return (
<div className={'flex w-full flex-col space-y-2'}>
<Alert variant={'destructive'}>
<Trans i18nKey={'account:qrCodeError'} />
</Alert>
</div>
);
}
if (!factorName) {
return (
<FactorNameForm
onCancel={onCancel}
onSetFactorName={(name) => {
setFactor((factor) => ({ ...factor, name }));
}}
/>
);
}
return (
<div className={'flex flex-col space-y-4'}>
<p>
<span className={'text-base'}>
<Trans i18nKey={'account:multiFactorModalHeading'} />
</span>
</p>
<div className={'flex justify-center'}>
<QrImage src={factor.qrCode} />
</div>
</div>
);
}
function FactorNameForm(
props: React.PropsWithChildren<{
onSetFactorName: (name: string) => void;
onCancel: () => void;
}>,
) {
const inputName = 'factorName';
return (
<form
className={'w-full'}
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const name = data.get(inputName) as string;
props.onSetFactorName(name);
}}
>
<div className={'flex flex-col space-y-4'}>
<Label>
<Trans i18nKey={'account:factorNameLabel'} />
<Input autoComplete={'off'} required name={inputName} />
<span>
<Trans i18nKey={'account:factorNameHint'} />
</span>
</Label>
<div className={'flex justify-end space-x-2'}>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
</Button>
</div>
</div>
</form>
);
}
function QrImage({ src }: { src: string }) {
return <Image alt={'QR Code'} src={src} width={160} height={160} />;
}
export default MultiFactorAuthSetupModal;
function useEnrollFactor() {
const client = useSupabase();
const mutationKey = useFactorsMutationKey();
const mutationFn = async (factorName: string) => {
const { data, error } = await client.auth.mfa.enroll({
friendlyName: factorName,
factorType: 'totp',
});
if (error) {
throw error;
}
return data;
};
return useMutation({
mutationFn,
mutationKey,
});
}
function useVerifyCodeMutation() {
const mutationKey = useFactorsMutationKey();
const client = useSupabase();
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 });
}

View File

@@ -25,7 +25,7 @@ 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';
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
export const UpdatePasswordForm = ({
user,

View File

@@ -8,6 +8,14 @@ import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-clie
import { PersonalAccountsService } from './services/personal-accounts.service';
export async function refreshAuthSession() {
const client = getSupabaseServerActionClient();
await client.auth.refreshSession();
return {};
}
export async function deletePersonalAccountAction(formData: FormData) {
const confirmation = formData.get('confirmation');

View File

@@ -1,20 +1,35 @@
'use client';
import type { FormEventHandler } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import useFetchAuthFactors from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { OtpInput } from '@kit/ui/otp-input';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
@@ -26,72 +41,128 @@ export function MultiFactorChallengeContainer({
};
}>) {
const router = useRouter();
const [factorId, setFactorId] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const verifyMFAChallenge = useVerifyMFAChallenge();
const onSuccess = useCallback(() => {
router.replace(paths.redirectPath);
}, [router, paths.redirectPath]);
const onSubmitClicked: FormEventHandler<HTMLFormElement> = useCallback(
(event) => {
void (async () => {
event.preventDefault();
if (!factorId || !verifyCode) {
return;
}
await verifyMFAChallenge.mutateAsync({
factorId,
verifyCode,
});
onSuccess();
})();
const verificationCodeForm = useForm({
resolver: zodResolver(
z.object({
factorId: z.string().min(1),
verificationCode: z.string().min(6).max(6),
}),
),
defaultValues: {
factorId: '',
verificationCode: '',
},
[factorId, verifyMFAChallenge, onSuccess, verifyCode],
);
});
const factorId = verificationCodeForm.watch('factorId');
if (!factorId) {
return (
<FactorsListContainer onSelect={setFactorId} onSuccess={onSuccess} />
<FactorsListContainer
onSelect={(factorId) => {
verificationCodeForm.setValue('factorId', factorId);
}}
onSuccess={onSuccess}
/>
);
}
return (
<form onSubmit={onSubmitClicked}>
<div className={'flex flex-col space-y-4'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:verifyActivationCodeDescription'} />
</span>
<Form {...verificationCodeForm}>
<form
className={'w-full'}
onSubmit={verificationCodeForm.handleSubmit(async (data) => {
await verifyMFAChallenge.mutateAsync({
factorId,
verificationCode: data.verificationCode,
});
<div className={'flex w-full flex-col space-y-2.5'}>
<OtpInput
onInvalid={() => setVerifyCode('')}
onValid={setVerifyCode}
/>
onSuccess();
})}
>
<div className={'flex flex-col space-y-4'}>
<span className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:verifyActivationCodeDescription'} />
</span>
<If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}>
<AlertDescription>
<Trans i18nKey={'account:invalidVerificationCode'} />
</AlertDescription>
</Alert>
</If>
<div className={'flex w-full flex-col space-y-2.5'}>
<div className={'flex flex-col space-y-4'}>
<If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-5'} />
<AlertTitle>
<Trans i18nKey={'account:invalidVerificationCodeHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={'account:invalidVerificationCodeDescription'}
/>
</AlertDescription>
</Alert>
</If>
<FormField
name={'verificationCode'}
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>
);
}}
/>
</div>
</div>
<Button
disabled={
verifyMFAChallenge.isPending ||
!verificationCodeForm.formState.isValid
}
>
{verifyMFAChallenge.isPending ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:submitVerificationCode'} />
)}
</Button>
</div>
<Button disabled={verifyMFAChallenge.isPending || !verifyCode}>
{verifyMFAChallenge.isPending ? (
<Trans i18nKey={'account:verifyingCode'} />
) : (
<Trans i18nKey={'account:submitVerificationCode'} />
)}
</Button>
</div>
</form>
</form>
</Form>
);
}
@@ -99,11 +170,12 @@ function useVerifyMFAChallenge() {
const client = useSupabase();
const mutationKey = ['mfa-verify-challenge'];
const mutationFn = async (params: {
factorId: string;
verifyCode: string;
verificationCode: string;
}) => {
const { factorId, verifyCode: code } = params;
const { factorId, verificationCode: code } = params;
const response = await client.auth.mfa.challengeAndVerify({
factorId,
@@ -128,7 +200,6 @@ function FactorsListContainer({
onSelect: (factor: string) => void;
}>) {
const signOut = useSignOut();
const { data: factors, isLoading, error } = useFetchAuthFactors();
const isSuccess = factors && !isLoading && !error;
@@ -174,8 +245,14 @@ function FactorsListContainer({
return (
<div className={'w-full'}>
<Alert variant={'destructive'}>
<AlertDescription>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
@@ -187,22 +264,24 @@ function FactorsListContainer({
return (
<div className={'flex flex-col space-y-4'}>
<div>
<Heading level={6}>
<span className={'font-medium'}>
<Trans i18nKey={'account:selectFactor'} />
</Heading>
</span>
</div>
{verifiedFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full border-gray-50'}
onClick={() => onSelect(factor.id)}
>
{factor.friendly_name}
</Button>
</div>
))}
<div className={'flex flex-col space-y-2'}>
{verifiedFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full'}
onClick={() => onSelect(factor.id)}
>
{factor.friendly_name}
</Button>
</div>
))}
</div>
</div>
);
}

View File

@@ -22,6 +22,7 @@
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0",
"@kit/ui": "*",
"@hookform/resolvers/zod": "1.0.0",
"lucide-react": "^0.363.0"
},
"peerDependencies": {

View File

@@ -118,8 +118,10 @@ export class StripeWebhookHandlerService
const stripe = await this.loadStripe();
const session = event.data.object;
const subscriptionId = session.subscription as string;
// TODO: handle one-off payments
// is subscription there?
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const accountId = session.client_reference_id!;

View File

@@ -28,6 +28,7 @@
"@radix-ui/react-tooltip": "1.0.7",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"input-otp": "1.2.3",
"react-top-loading-bar": "2.3.1",
"tailwind-merge": "^2.2.0"
},
@@ -75,6 +76,7 @@
"prettier": "@kit/prettier-config",
"exports": {
"./accordion": "./src/shadcn/accordion.tsx",
"./alert-dialog": "./src/shadcn/alert-dialog.tsx",
"./avatar": "./src/shadcn/avatar.tsx",
"./button": "./src/shadcn/button.tsx",
"./calendar": "./src/shadcn/calendar.tsx",
@@ -101,6 +103,7 @@
"./badge": "./src/shadcn/badge.tsx",
"./radio-group": "./src/shadcn/radio-group.tsx",
"./separator": "./src/shadcn/separator.tsx",
"./input-otp": "./src/shadcn/input-otp.tsx",
"./utils": "./src/utils/index.ts",
"./if": "./src/makerkit/if.tsx",
"./trans": "./src/makerkit/trans.tsx",
@@ -113,7 +116,6 @@
"./global-loader": "./src/makerkit/global-loader.tsx",
"./error-boundary": "./src/makerkit/error-boundary.tsx",
"./auth-change-listener": "./src/makerkit/auth-change-listener.tsx",
"./otp-input": "./src/makerkit/otp-input.tsx",
"./loading-overlay": "./src/makerkit/loading-overlay.tsx",
"./profile-avatar": "./src/makerkit/profile-avatar.tsx",
"./mdx": "./src/makerkit/mdx/mdx-renderer.tsx"

View File

@@ -1,143 +0,0 @@
import type { FormEventHandler } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Input } from '../shadcn/input';
const DIGITS = 6;
export function OtpInput({
onValid,
onInvalid,
}: React.PropsWithChildren<{
onValid: (code: string) => void;
onInvalid: () => void;
}>) {
const digitsArray = useMemo(
() => Array.from({ length: DIGITS }, (_, i) => i),
[],
);
const { control, register, watch, setFocus, formState, setValue } = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
values: digitsArray.map(() => ({ value: '' })),
},
});
useFieldArray({
control,
name: 'values',
shouldUnregister: true,
});
const { values } = watch();
const isFormValid = formState.isValid;
const code = (values ?? []).map(({ value }) => value).join('');
useEffect(() => {
if (!isFormValid) {
onInvalid();
return;
}
if (code.length === DIGITS) {
onValid(code);
return;
}
onInvalid();
}, [onInvalid, onValid, code, isFormValid]);
useEffect(() => {
setFocus('values.0.value');
}, [setFocus]);
const onInput: FormEventHandler<HTMLInputElement> = useCallback(
(target) => {
const element = target.currentTarget;
const isValid = element.reportValidity();
if (isValid) {
const nextIndex = Number(element.dataset.index) + 1;
if (nextIndex >= DIGITS) {
return;
}
setFocus(`values.${nextIndex}.value`);
}
},
[setFocus],
);
const onPaste = useCallback(
(event: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = event.clipboardData.getData('text/plain');
// check if value is numeric
if (isNumeric(pasted)) {
const digits = getDigits(pasted, digitsArray);
digits.forEach((value, index) => {
setValue(`values.${index}.value`, value);
setFocus(`values.${index + 1}.value`);
});
}
},
[digitsArray, setFocus, setValue],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace') {
event.preventDefault();
const index = Number(event.currentTarget.dataset.inputIndex);
setValue(`values.${index}.value`, '');
setFocus(`values.${index - 1}.value`);
}
},
[setFocus, setValue],
);
return (
<div className={'flex justify-center space-x-2'}>
{digitsArray.map((digit, index) => {
const control = { ...register(`values.${digit}.value`) };
return (
<Input
autoComplete={'off'}
className={'w-10 text-center'}
data-index={digit}
pattern="[0-9]"
required
key={digit}
maxLength={1}
onInput={onInput}
onPaste={onPaste}
onKeyDown={handleKeyDown}
data-input-index={index}
{...control}
/>
);
})}
</div>
);
}
function isNumeric(pasted: string) {
const isNumericRegExp = /^-?\d+$/;
return isNumericRegExp.test(pasted);
}
function getDigits(pasted: string, digitsArray: number[]) {
return pasted.split('').slice(0, digitsArray.length);
}

View File

@@ -4,9 +4,8 @@ import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { buttonVariants } from '@kit/ui/button';
import { cn } from '../utils/cn';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;

View File

@@ -0,0 +1,72 @@
'use client';
import * as React from 'react';
import { DashIcon } from '@radix-ui/react-icons';
import { OTPInput, OTPInputContext } from 'input-otp';
import { cn } from '../utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-1 ring-ring',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<DashIcon />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

119
pnpm-lock.yaml generated
View File

@@ -88,7 +88,7 @@ importers:
version: 5.28.6(react@18.2.0)
'@tanstack/react-query-next-experimental':
specifier: ^5.28.6
version: 5.28.8(@tanstack/react-query@5.28.6)(next@14.2.0-canary.43)(react@18.2.0)
version: 5.28.8(@tanstack/react-query@5.28.6)(next@14.2.0-canary.44)(react@18.2.0)
'@tanstack/react-table':
specifier: ^8.11.3
version: 8.15.0(react-dom@18.2.0)(react@18.2.0)
@@ -100,7 +100,7 @@ importers:
version: 3.6.0
edge-csrf:
specifier: ^1.0.9
version: 1.0.9(next@14.2.0-canary.43)
version: 1.0.9(next@14.2.0-canary.44)
i18next:
specifier: ^23.10.1
version: 23.10.1
@@ -108,17 +108,17 @@ importers:
specifier: ^1.2.0
version: 1.2.0
next:
specifier: canary
version: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
specifier: 14.2.0-canary.44
version: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next-contentlayer:
specifier: 0.3.4
version: 0.3.4(contentlayer@0.3.4)(esbuild@0.20.2)(next@14.2.0-canary.43)(react-dom@18.2.0)(react@18.2.0)
version: 0.3.4(contentlayer@0.3.4)(esbuild@0.20.2)(next@14.2.0-canary.44)(react-dom@18.2.0)(react@18.2.0)
next-sitemap:
specifier: ^4.2.3
version: 4.2.3(next@14.2.0-canary.43)
version: 4.2.3(next@14.2.0-canary.44)
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.2.0-canary.43)(react-dom@18.2.0)(react@18.2.0)
version: 0.2.1(next@14.2.0-canary.44)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -163,8 +163,8 @@ importers:
specifier: ^0.1.0
version: link:../../tooling/typescript
'@next/bundle-analyzer':
specifier: canary
version: 14.2.0-canary.43
specifier: 14.2.0-canary.44
version: 14.2.0-canary.44
'@types/mdx':
specifier: ^2.0.10
version: 2.0.12
@@ -610,6 +610,9 @@ importers:
cmdk:
specifier: ^0.2.0
version: 0.2.1(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
input-otp:
specifier: 1.2.3
version: 1.2.3(react-dom@18.2.0)(react@18.2.0)
react-top-loading-bar:
specifier: 2.3.1
version: 2.3.1(react@18.2.0)
@@ -1958,8 +1961,8 @@ packages:
- supports-color
dev: false
/@next/bundle-analyzer@14.2.0-canary.43:
resolution: {integrity: sha512-5GYBb99OLnmg5xZDrUUD0ILB/gJDN4MxJTG5fU5JQXIDc6Ew+jJgMzjdqptJduvlExorAWNNpQnjdnRlnZCQfg==}
/@next/bundle-analyzer@14.2.0-canary.44:
resolution: {integrity: sha512-713lVU5ubs+w1FFAVhH6N9QhZHRv/cvq/bohkYXm1KBX4vHS8/d+cieLT6HUw1KZj673R+rn+/wNZ4a/3QJnPA==}
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
@@ -1975,8 +1978,8 @@ packages:
resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==}
dev: false
/@next/env@14.2.0-canary.43:
resolution: {integrity: sha512-jBjfC5J053shwv+g4kplFG+iH1TqWwMtLCIpDSplOmRDLdGeai6s3oKmWIxd+MbG5ETSZOl1vCN5A3nMgGkXfg==}
/@next/env@14.2.0-canary.44:
resolution: {integrity: sha512-I4pQqivxUD8jgAy8WomJhniJmdEUmdfNPTTXunsRUPHkWU3a6rlGc90fYrb+4+Bm/szyXEhBesFaB6n27NWjEw==}
dev: false
/@next/eslint-plugin-next@14.1.4:
@@ -2008,8 +2011,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-arm64@14.2.0-canary.43:
resolution: {integrity: sha512-M9Asj8J6GMVNdMRnDnR+hELiyjgaHSUYAZz4M7ro5Vd1X8wpg3jygd/RnkTv+hhHn3rqwV9jWyZ4xdyG3SORrg==}
/@next/swc-darwin-arm64@14.2.0-canary.44:
resolution: {integrity: sha512-P9gmEH5fSTL2E3pMVfsIB2o5qqVsdNSfpq8puNYcbMNvpvwhGP9mgx9gOybf8FdfxYSGCGnLjeit/3ja0LJIeQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -2026,8 +2029,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@14.2.0-canary.43:
resolution: {integrity: sha512-3BQ5FirbYZgXAFOCUynDr/Sl0fcFfEiLiDVdGMaJO7754fuWJShcj5tODiFC2B7MgLsVkri/84prBzsjkg76jA==}
/@next/swc-darwin-x64@14.2.0-canary.44:
resolution: {integrity: sha512-yiakf77DTsX6uKEW1bNcV4ST654OzR9svNdhIz3gqti17SVF2LnVhJGMZ8VcQhqIvQe6zeG4m2HXvWFsxGURuQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -2044,8 +2047,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@14.2.0-canary.43:
resolution: {integrity: sha512-VoCLYDTD2bkLsUkT0bACplrdpTw+IBKdFr5ih85atePrujCz6dMPUxeNMwH9aYL7r3PgzH6dR30r0Y5TFwUUSg==}
/@next/swc-linux-arm64-gnu@14.2.0-canary.44:
resolution: {integrity: sha512-ZdzSCApQdooLTQOc4hQdHQLiidYv3nImyjhkZF2ol1Rb3JBLtuTVx2zg3GjBN/aypUBx67kpWZoPIPbIlYcKpA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2062,8 +2065,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@14.2.0-canary.43:
resolution: {integrity: sha512-8c35oylAS4Ggu155txTpOv7VG4BzG8BTluVbUZuaneZwsZi6VTbjVKMVnLYmmdcdRkkvRgPc83oUr2HGxwxFBw==}
/@next/swc-linux-arm64-musl@14.2.0-canary.44:
resolution: {integrity: sha512-hh1zk4yEKDRbLivQqH04Ve+bB+baivK5/mKnFSDAaP7fKiLu6RA71J7oh2lUksgDOVTLqpR2ApHKyHbnNKVCcQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2080,8 +2083,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@14.2.0-canary.43:
resolution: {integrity: sha512-PHy7clJ+ChZzNJ3c9A2IrWJN4aNa+FZ+v39XNdcjdkdhPvwu1QSvtirWSbxqKpAqgA/3sMhAGCvwOx6yeBs4Ug==}
/@next/swc-linux-x64-gnu@14.2.0-canary.44:
resolution: {integrity: sha512-Dt7SpIQrimsTAlWNHBzb3gwG01s4X2rVtX8RCVfO3Wqb6bqb0sFHxJQ7MWDql9CtbQLvwEPvWwRz0RPtEFK5Cg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2098,8 +2101,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@14.2.0-canary.43:
resolution: {integrity: sha512-pvma+GKwkDEzhQRrwl9P4oGu9A9NGJH/Za+SG/XwWph2i78+4OMDCKrmKEJ1T5BE6Bgo+Emfhdy8TmfqHPQQCg==}
/@next/swc-linux-x64-musl@14.2.0-canary.44:
resolution: {integrity: sha512-VNUjddGcYepo9Pqn6O9uENlHNdKsOV8gjqLYvGPs820NVxRUSRw4yhUUmQSzTiJT+XxYlQ1XsT8DE5aWgCoxtA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2116,8 +2119,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@14.2.0-canary.43:
resolution: {integrity: sha512-b1npBheIu7/BgMZTCFkuNYv0Q/N9u6+7MYY5xjZDVIutW8ut2V93JZqeC2SYWFm03I+LNdYjplRhn3TVerz9Xg==}
/@next/swc-win32-arm64-msvc@14.2.0-canary.44:
resolution: {integrity: sha512-2lBxvpMqErzTsMDPdnzMw/IcRp1SYkSzL/UuScgvbE7gSDnMlur8PtAM5MMwxQFdMAEbcnefjxH4kjOuwQagyw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -2134,8 +2137,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@14.2.0-canary.43:
resolution: {integrity: sha512-1bZDCGyQzvdRNxVUUhsjBZOzBEEoQlh1r91ifjUz9nhcFYOlmP6IplPMjaLmG+GJMUiI36j5svdPYO3LP08b8g==}
/@next/swc-win32-ia32-msvc@14.2.0-canary.44:
resolution: {integrity: sha512-yadLbdDfXvn5uPNPyxmXdHUNTEyBNLspF87kbGVHM1HsffTaqgZuQZy52E247aoATJ/g951fYAqi7pxA61rWwg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@@ -2152,8 +2155,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@14.2.0-canary.43:
resolution: {integrity: sha512-pU9gjLmp4yjYzBqCGa5bQ0iyJ5D73IRITEUFKrjZPi0XHUbFLrhcaaCsnVgMO4xfOQJgS7ODuQB7N0iPk7/EMw==}
/@next/swc-win32-x64-msvc@14.2.0-canary.44:
resolution: {integrity: sha512-t+0EgPaqQVDmBxrbOWEi3R+WeOg9jDhBYSqtyJO1n8Fou34xApsWzdgv9vSuRsAhU4CoMJQQkzm04FMIJReHmw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -4318,7 +4321,7 @@ packages:
/@tanstack/query-core@5.28.6:
resolution: {integrity: sha512-hnhotV+DnQtvtR3jPvbQMPNMW4KEK0J4k7c609zJ8muiNknm+yoDyMHmxTWM5ZnlZpsz0zOxYFr+mzRJNHWJsA==}
/@tanstack/react-query-next-experimental@5.28.8(@tanstack/react-query@5.28.6)(next@14.2.0-canary.43)(react@18.2.0):
/@tanstack/react-query-next-experimental@5.28.8(@tanstack/react-query@5.28.6)(next@14.2.0-canary.44)(react@18.2.0):
resolution: {integrity: sha512-1JAh1SHrqX1PPfoJtEiS8ewvz7D3lkBsIvDCpE8hWB07EF4O8hxPWQiVDf/fJ7U2g6N7iARX74335BHpCg250Q==}
peerDependencies:
'@tanstack/react-query': ^5.28.8
@@ -4326,7 +4329,7 @@ packages:
react: ^18.0.0
dependencies:
'@tanstack/react-query': 5.28.6(react@18.2.0)
next: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
dev: false
@@ -6179,12 +6182,12 @@ packages:
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
/edge-csrf@1.0.9(next@14.2.0-canary.43):
/edge-csrf@1.0.9(next@14.2.0-canary.44):
resolution: {integrity: sha512-3F89YTh42UDdISr3s9AEcgJDLi4ysgjGfnybzF0LuZGaG2W31h1ZwgWwEQBLMj04lAklcP4XHZYi7vk9o8zcbg==}
peerDependencies:
next: ^13.0.0 || ^14.0.0
dependencies:
next: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
dev: false
/editorconfig@1.0.4:
@@ -7626,6 +7629,16 @@ packages:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
dev: false
/input-otp@1.2.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pxYvgnihL9KAdpcShX2+iKctdMRbDs36bIqd8uIsN3e5vv9VjMv2bhO3S5Bl1PjcDPsA/OXZe5R71n8oVtucfQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/inquirer@7.3.3:
resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
engines: {node: '>=8.0.0'}
@@ -8926,7 +8939,7 @@ packages:
engines: {node: '>= 0.4.0'}
dev: false
/next-contentlayer@0.3.4(contentlayer@0.3.4)(esbuild@0.20.2)(next@14.2.0-canary.43)(react-dom@18.2.0)(react@18.2.0):
/next-contentlayer@0.3.4(contentlayer@0.3.4)(esbuild@0.20.2)(next@14.2.0-canary.44)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-UtUCwgAl159KwfhNaOwyiI7Lg6sdioyKMeh+E7jxx0CJ29JuXGxBEYmCI6+72NxFGIFZKx8lvttbbQhbnYWYSw==}
peerDependencies:
contentlayer: 0.3.4
@@ -8937,7 +8950,7 @@ packages:
'@contentlayer/core': 0.3.4(esbuild@0.20.2)
'@contentlayer/utils': 0.3.4
contentlayer: 0.3.4(esbuild@0.20.2)
next: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
@@ -8947,7 +8960,7 @@ packages:
- supports-color
dev: false
/next-sitemap@4.2.3(next@14.2.0-canary.43):
/next-sitemap@4.2.3(next@14.2.0-canary.44):
resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==}
engines: {node: '>=14.18'}
hasBin: true
@@ -8958,17 +8971,17 @@ packages:
'@next/env': 13.5.6
fast-glob: 3.3.2
minimist: 1.2.8
next: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
dev: false
/next-themes@0.2.1(next@14.2.0-canary.43)(react-dom@18.2.0)(react@18.2.0):
/next-themes@0.2.1(next@14.2.0-canary.44)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==}
peerDependencies:
next: '*'
react: '*'
react-dom: '*'
dependencies:
next: 14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
next: 14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
@@ -9012,8 +9025,8 @@ packages:
- babel-plugin-macros
dev: false
/next@14.2.0-canary.43(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-tL5fxsleOuRS7Momx5wRwkCOPLybQKwgJnpzgMGVReQs+kA9lkQiBANvlYdAsrvZ3vjzx2H+9mSqKDcKaC8UXQ==}
/next@14.2.0-canary.44(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/f26kB0PHbggCRt6WSre695wKCYzCVQed1K3UFp5830wD6OMLM6j/ZMBgHx0AXD/JPR3dq2T2slX6LLEyYdnVw==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@@ -9030,7 +9043,7 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 14.2.0-canary.43
'@next/env': 14.2.0-canary.44
'@opentelemetry/api': 1.8.0
'@swc/helpers': 0.5.5
busboy: 1.6.0
@@ -9041,15 +9054,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.0-canary.43
'@next/swc-darwin-x64': 14.2.0-canary.43
'@next/swc-linux-arm64-gnu': 14.2.0-canary.43
'@next/swc-linux-arm64-musl': 14.2.0-canary.43
'@next/swc-linux-x64-gnu': 14.2.0-canary.43
'@next/swc-linux-x64-musl': 14.2.0-canary.43
'@next/swc-win32-arm64-msvc': 14.2.0-canary.43
'@next/swc-win32-ia32-msvc': 14.2.0-canary.43
'@next/swc-win32-x64-msvc': 14.2.0-canary.43
'@next/swc-darwin-arm64': 14.2.0-canary.44
'@next/swc-darwin-x64': 14.2.0-canary.44
'@next/swc-linux-arm64-gnu': 14.2.0-canary.44
'@next/swc-linux-arm64-musl': 14.2.0-canary.44
'@next/swc-linux-x64-gnu': 14.2.0-canary.44
'@next/swc-linux-x64-musl': 14.2.0-canary.44
'@next/swc-win32-arm64-msvc': 14.2.0-canary.44
'@next/swc-win32-ia32-msvc': 14.2.0-canary.44
'@next/swc-win32-x64-msvc': 14.2.0-canary.44
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros