Finally have all email verification / password reset auth flows working!
This commit is contained in:
@@ -35,7 +35,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.1.0",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.1",
|
"react-native-safe-area-context": "~5.6.1",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.1",
|
"react-native-web": "~0.21.1",
|
||||||
|
@@ -32,8 +32,9 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"convex": "^1.27.3",
|
"convex": "^1.27.3",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.4",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -55,9 +56,9 @@
|
|||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"eslint-config-next": "^15.5.3",
|
"eslint-config-next": "^15.5.4",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.3.8"
|
"tw-animate-css": "^1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
apps/next/src/app/(auth)/forgot-password/layout.tsx
Normal file
14
apps/next/src/app/(auth)/forgot-password/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const generateMetadata = (): Metadata => {
|
||||||
|
return {
|
||||||
|
title: 'Forgot Password',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
};
|
||||||
|
export default ProfileLayout;
|
298
apps/next/src/app/(auth)/forgot-password/page.tsx
Normal file
298
apps/next/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
'use client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
SubmitButton,
|
||||||
|
InputOTPSeparator,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { PASSWORD_MIN, PASSWORD_MAX } from '@/lib/types';
|
||||||
|
|
||||||
|
const forgotPasswordSchema = z.object({
|
||||||
|
email: z.email({ message: 'Invalid email.' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetVerificationSchema = z
|
||||||
|
.object({
|
||||||
|
code: z.string({ message: 'Invalid code.' }),
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
})
|
||||||
|
.max(PASSWORD_MAX, {
|
||||||
|
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||||
|
})
|
||||||
|
.regex(/^\S+$/, {
|
||||||
|
message: 'Password must not contain whitespace.',
|
||||||
|
})
|
||||||
|
.regex(/[0-9]/, {
|
||||||
|
message: 'Password must contain at least one digit.',
|
||||||
|
})
|
||||||
|
.regex(/[a-z]/, {
|
||||||
|
message: 'Password must contain at least one lowercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[A-Z]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter.',
|
||||||
|
})
|
||||||
|
.regex(/[\p{P}\p{S}]/u, {
|
||||||
|
message: 'Password must contain at least one symbol.',
|
||||||
|
}),
|
||||||
|
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||||
|
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ForgotPassword = () => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
|
||||||
|
resolver: zodResolver(forgotPasswordSchema),
|
||||||
|
defaultValues: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetVerificationForm = useForm<
|
||||||
|
z.infer<typeof resetVerificationSchema>
|
||||||
|
>({
|
||||||
|
resolver: zodResolver(resetVerificationSchema),
|
||||||
|
defaultValues: { code, newPassword: '', confirmPassword: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleForgotPasswordSubmit = async (
|
||||||
|
values: z.infer<typeof forgotPasswordSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('email', values.email);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData).then(() => {
|
||||||
|
setEmail(values.email);
|
||||||
|
setFlow('reset-verification');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting password: ', error);
|
||||||
|
toast.error('Error resetting password.');
|
||||||
|
} finally {
|
||||||
|
forgotPasswordForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetVerificationSubmit = async (
|
||||||
|
values: z.infer<typeof resetVerificationSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('code', code);
|
||||||
|
formData.append('newPassword', values.newPassword);
|
||||||
|
formData.append('email', email);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData);
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting password: ', error);
|
||||||
|
toast.error('Error resetting password.');
|
||||||
|
} finally {
|
||||||
|
resetVerificationForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
<Card className='p-4 bg-card/25 min-h-[400px] w-sm lg:w-md'>
|
||||||
|
<CardHeader className='flex flex-col gap-4 items-center'>
|
||||||
|
{flow === 'reset' ? (
|
||||||
|
<>
|
||||||
|
<CardTitle className='font-bold text-2xl'>
|
||||||
|
Forgot Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email address and we will send you a link to reset
|
||||||
|
your password.
|
||||||
|
</CardDescription>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardTitle className='font-bold text-2xl'>
|
||||||
|
Reset Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your code and new password and we will reset your
|
||||||
|
password.
|
||||||
|
</CardDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Card className='bg-card/50'>
|
||||||
|
<CardContent>
|
||||||
|
{flow === 'reset' ? (
|
||||||
|
<Form {...forgotPasswordForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={forgotPasswordForm.handleSubmit(
|
||||||
|
handleForgotPasswordSubmit,
|
||||||
|
)}
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={forgotPasswordForm.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex flex-col w-full items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Sending Email...'
|
||||||
|
className='text-xl font-semibold w-2/3 mx-auto'
|
||||||
|
>
|
||||||
|
Send Email
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Form {...resetVerificationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={resetVerificationForm.handleSubmit(
|
||||||
|
handleResetVerificationSubmit,
|
||||||
|
)}
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value)}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Please enter the one-time password sent to your
|
||||||
|
phone.
|
||||||
|
</FormDescription>
|
||||||
|
<div className='flex flex-col w-full items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='newPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>
|
||||||
|
New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex flex-col w-full items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={resetVerificationForm.control}
|
||||||
|
name='confirmPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>
|
||||||
|
Confirm Passsword
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className='flex flex-col w-full items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Resetting Password...'
|
||||||
|
className='text-xl font-semibold w-2/3 mx-auto'
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ForgotPassword;
|
@@ -12,11 +12,16 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
InputOTPSeparator,
|
||||||
Separator,
|
Separator,
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -45,7 +50,7 @@ const signUpFormSchema = z
|
|||||||
name: z.string().min(2, {
|
name: z.string().min(2, {
|
||||||
message: 'Name must be at least 2 characters.',
|
message: 'Name must be at least 2 characters.',
|
||||||
}),
|
}),
|
||||||
email: z.string().email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z
|
password: z
|
||||||
@@ -80,9 +85,17 @@ const signUpFormSchema = z
|
|||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const verifyEmailFormSchema = z.object({
|
||||||
|
code: z.string({ message: 'Invalid code.' }),
|
||||||
|
});
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
|
||||||
|
'signIn',
|
||||||
|
);
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -95,12 +108,17 @@ const SignIn = () => {
|
|||||||
resolver: zodResolver(signUpFormSchema),
|
resolver: zodResolver(signUpFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email,
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
|
||||||
|
resolver: zodResolver(verifyEmailFormSchema),
|
||||||
|
defaultValues: { code },
|
||||||
|
});
|
||||||
|
|
||||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
@@ -108,13 +126,12 @@ const SignIn = () => {
|
|||||||
formData.append('flow', flow);
|
formData.append('flow', flow);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn('password', formData);
|
await signIn('password', formData).then(() => router.push('/'));
|
||||||
signInForm.reset();
|
|
||||||
router.push('/');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing in:', error);
|
console.error('Error signing in:', error);
|
||||||
toast.error('Error signing in.');
|
toast.error('Error signing in.');
|
||||||
} finally {
|
} finally {
|
||||||
|
signInForm.reset();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -129,17 +146,107 @@ const SignIn = () => {
|
|||||||
try {
|
try {
|
||||||
if (values.confirmPassword !== values.password)
|
if (values.confirmPassword !== values.password)
|
||||||
throw new ConvexError('Passwords do not match.');
|
throw new ConvexError('Passwords do not match.');
|
||||||
await signIn('password', formData);
|
await signIn('password', formData).then(() => {
|
||||||
signUpForm.reset();
|
setEmail(values.email);
|
||||||
router.push('/');
|
setFlow('email-verification');
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing up:', error);
|
console.error('Error signing up:', error);
|
||||||
toast.error('Error signing up.');
|
toast.error('Error signing up.');
|
||||||
} finally {
|
} finally {
|
||||||
|
signUpForm.reset();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyEmail = async (
|
||||||
|
values: z.infer<typeof verifyEmailFormSchema>,
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('code', code);
|
||||||
|
formData.append('flow', flow);
|
||||||
|
formData.append('email', email);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', formData).then(() => router.push('/'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying email:', error);
|
||||||
|
toast.error('Error verifying email.');
|
||||||
|
} finally {
|
||||||
|
verifyEmailForm.reset();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flow === 'email-verification') {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-center mb-6'>
|
||||||
|
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||||
|
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||||
|
</div>
|
||||||
|
<Form {...verifyEmailForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||||
|
className='flex flex-col space-y-8'
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={verifyEmailForm.control}
|
||||||
|
name='code'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className='text-xl'>Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value)}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Please enter the one-time password sent to your email.
|
||||||
|
</FormDescription>
|
||||||
|
<div className='flex flex-col w-full items-center'>
|
||||||
|
<FormMessage className='w-5/6 text-center' />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
disabled={loading}
|
||||||
|
pendingText='Signing Up...'
|
||||||
|
className='text-xl font-semibold w-2/3 mx-auto'
|
||||||
|
>
|
||||||
|
Verify Email
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<div className='text-center mt-4'>
|
||||||
|
<button
|
||||||
|
onClick={() => setFlow('signUp')}
|
||||||
|
className='text-sm text-muted-foreground hover:underline'
|
||||||
|
>
|
||||||
|
Back to Sign Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center'>
|
<div className='flex flex-col items-center'>
|
||||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||||
|
@@ -113,7 +113,7 @@ export const StatusTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className={containerCn}>
|
<div className={containerCn}>
|
||||||
<div className={headerCn}>
|
<div className={headerCn}>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex flex-col w-full gap-2 items-end'>
|
||||||
{!tvMode && (
|
{!tvMode && (
|
||||||
<div className='flex flex-row gap-2 text-xs'>
|
<div className='flex flex-row gap-2 text-xs'>
|
||||||
<p className='text-muted-foreground'>Tired of the old table? </p>
|
<p className='text-muted-foreground'>Tired of the old table? </p>
|
||||||
|
@@ -55,6 +55,12 @@ export {
|
|||||||
ImageCropReset,
|
ImageCropReset,
|
||||||
} from './shadcn-io/image-crop';
|
} from './shadcn-io/image-crop';
|
||||||
export { Input } from './input';
|
export { Input } from './input';
|
||||||
|
export {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
InputOTPSeparator,
|
||||||
|
} from './input-otp';
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
export {
|
export {
|
||||||
Pagination,
|
Pagination,
|
||||||
|
77
apps/next/src/components/ui/input-otp.tsx
Normal file
77
apps/next/src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||||
|
import { MinusIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot='input-otp'
|
||||||
|
containerClassName={cn(
|
||||||
|
'flex items-center gap-2 has-disabled:opacity-50',
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
className={cn('disabled:cursor-not-allowed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='input-otp-group'
|
||||||
|
className={cn('flex items-center', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='input-otp-slot'
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
|
||||||
|
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot='input-otp-separator' role='separator' {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
36
packages/backend/convex/_generated/api.d.ts
vendored
36
packages/backend/convex/_generated/api.d.ts
vendored
@@ -12,18 +12,16 @@ import type {
|
|||||||
ApiFromModules,
|
ApiFromModules,
|
||||||
FilterApi,
|
FilterApi,
|
||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from 'convex/server';
|
} from "convex/server";
|
||||||
import type * as auth from '../auth.js';
|
import type * as auth from "../auth.js";
|
||||||
import type * as crons from '../crons.js';
|
import type * as crons from "../crons.js";
|
||||||
import type * as custom_auth_index from '../custom/auth/index.js';
|
import type * as custom_auth_index from "../custom/auth/index.js";
|
||||||
import type * as custom_auth_password_reset from '../custom/auth/password/reset.js';
|
import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
|
||||||
import type * as custom_auth_password_validate from '../custom/auth/password/validate.js';
|
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
||||||
import type * as custom_auth_providers_entra from '../custom/auth/providers/entra.js';
|
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
||||||
import type * as custom_auth_providers_password from '../custom/auth/providers/password.js';
|
import type * as files from "../files.js";
|
||||||
import type * as custom_auth_providers_usesend from '../custom/auth/providers/usesend.js';
|
import type * as http from "../http.js";
|
||||||
import type * as files from '../files.js';
|
import type * as statuses from "../statuses.js";
|
||||||
import type * as http from '../http.js';
|
|
||||||
import type * as statuses from '../statuses.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
@@ -36,21 +34,19 @@ import type * as statuses from '../statuses.js';
|
|||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
'custom/auth/index': typeof custom_auth_index;
|
"custom/auth/index": typeof custom_auth_index;
|
||||||
'custom/auth/password/reset': typeof custom_auth_password_reset;
|
"custom/auth/providers/entra": typeof custom_auth_providers_entra;
|
||||||
'custom/auth/password/validate': typeof custom_auth_password_validate;
|
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
||||||
'custom/auth/providers/entra': typeof custom_auth_providers_entra;
|
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
||||||
'custom/auth/providers/password': typeof custom_auth_providers_password;
|
|
||||||
'custom/auth/providers/usesend': typeof custom_auth_providers_usesend;
|
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
statuses: typeof statuses;
|
statuses: typeof statuses;
|
||||||
}>;
|
}>;
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
typeof fullApi,
|
typeof fullApi,
|
||||||
FunctionReference<any, 'public'>
|
FunctionReference<any, "public">
|
||||||
>;
|
>;
|
||||||
export declare const internal: FilterApi<
|
export declare const internal: FilterApi<
|
||||||
typeof fullApi,
|
typeof fullApi,
|
||||||
FunctionReference<any, 'internal'>
|
FunctionReference<any, "internal">
|
||||||
>;
|
>;
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { anyApi } from 'convex/server';
|
import { anyApi } from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
@@ -13,9 +13,9 @@ import type {
|
|||||||
DocumentByName,
|
DocumentByName,
|
||||||
TableNamesInDataModel,
|
TableNamesInDataModel,
|
||||||
SystemTableNames,
|
SystemTableNames,
|
||||||
} from 'convex/server';
|
} from "convex/server";
|
||||||
import type { GenericId } from 'convex/values';
|
import type { GenericId } from "convex/values";
|
||||||
import schema from '../schema.js';
|
import schema from "../schema.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The names of all of your Convex tables.
|
* The names of all of your Convex tables.
|
||||||
|
16
packages/backend/convex/_generated/server.d.ts
vendored
16
packages/backend/convex/_generated/server.d.ts
vendored
@@ -18,8 +18,8 @@ import {
|
|||||||
GenericQueryCtx,
|
GenericQueryCtx,
|
||||||
GenericDatabaseReader,
|
GenericDatabaseReader,
|
||||||
GenericDatabaseWriter,
|
GenericDatabaseWriter,
|
||||||
} from 'convex/server';
|
} from "convex/server";
|
||||||
import type { DataModel } from './dataModel.js';
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
@@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js';
|
|||||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const query: QueryBuilder<DataModel, 'public'>;
|
export declare const query: QueryBuilder<DataModel, "public">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
|
|||||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a mutation in this Convex app's public API.
|
* Define a mutation in this Convex app's public API.
|
||||||
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
|||||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
|||||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an action in this Convex app's public API.
|
* Define an action in this Convex app's public API.
|
||||||
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
|||||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const action: ActionBuilder<DataModel, 'public'>;
|
export declare const action: ActionBuilder<DataModel, "public">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
|
|||||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
|
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an HTTP action.
|
* Define an HTTP action.
|
||||||
|
@@ -16,7 +16,7 @@ import {
|
|||||||
internalActionGeneric,
|
internalActionGeneric,
|
||||||
internalMutationGeneric,
|
internalMutationGeneric,
|
||||||
internalQueryGeneric,
|
internalQueryGeneric,
|
||||||
} from 'convex/server';
|
} from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
export { validatePassword } from './password/validate';
|
|
||||||
export { Entra } from './providers/entra';
|
export { Entra } from './providers/entra';
|
||||||
export { Password } from './providers/password';
|
export { Password, validatePassword } from './providers/password';
|
||||||
export { Usesend } from './providers/usesend';
|
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';
|
||||||
|
@@ -1,30 +0,0 @@
|
|||||||
import { Usesend } from '..';
|
|
||||||
import { UseSend } from 'usesend-js';
|
|
||||||
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
|
|
||||||
|
|
||||||
export const UsesendOTPPasswordReset = Usesend({
|
|
||||||
id: 'unsend-otp',
|
|
||||||
apiKey: process.env.AUTH_USESEND_API_KEY,
|
|
||||||
async generateVerificationToken() {
|
|
||||||
const random: RandomReader = {
|
|
||||||
read(bytes) {
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const alphabet = '0123456789';
|
|
||||||
const length = 8;
|
|
||||||
return generateRandomString(random, alphabet, length);
|
|
||||||
},
|
|
||||||
async sendVerificationRequest({ identifier: email, provider, token }) {
|
|
||||||
const useSend = new UseSend(provider.apiKey, 'https://usesend.gbrown.org');
|
|
||||||
const { error } = await useSend.emails.send({
|
|
||||||
to: [email],
|
|
||||||
from:
|
|
||||||
provider.from ??
|
|
||||||
'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
|
|
||||||
subject: `Reset your password - TechTracker`,
|
|
||||||
text: `Your password reset code is ${token}`,
|
|
||||||
});
|
|
||||||
if (error) throw new Error('Usesend error: ' + error.message);
|
|
||||||
},
|
|
||||||
});
|
|
@@ -1,12 +0,0 @@
|
|||||||
export const validatePassword = (password: string): boolean => {
|
|
||||||
if (
|
|
||||||
password.length < 8 ||
|
|
||||||
password.length > 100 ||
|
|
||||||
!/\d/.test(password) ||
|
|
||||||
!/[a-z]/.test(password) ||
|
|
||||||
!/[A-Z]/.test(password)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
@@ -1,8 +1,7 @@
|
|||||||
import { ConvexError } from 'convex/values';
|
|
||||||
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
||||||
import { validatePassword } from '../password/validate';
|
import { DataModel } from '../../../_generated/dataModel';
|
||||||
import type { DataModel } from '../../../_generated/dataModel';
|
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
|
||||||
import { UsesendOTPPasswordReset } from '../password/reset';
|
import { ConvexError } from 'convex/values';
|
||||||
|
|
||||||
export const Password = DefaultPassword<DataModel>({
|
export const Password = DefaultPassword<DataModel>({
|
||||||
profile(params, ctx) {
|
profile(params, ctx) {
|
||||||
@@ -16,5 +15,19 @@ export const Password = DefaultPassword<DataModel>({
|
|||||||
throw new ConvexError('Invalid password.');
|
throw new ConvexError('Invalid password.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reset: UsesendOTPPasswordReset,
|
reset: UseSendOTPPasswordReset,
|
||||||
|
verify: UseSendOTP,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const validatePassword = (password: string): boolean => {
|
||||||
|
if (
|
||||||
|
password.length < 8 ||
|
||||||
|
password.length > 100 ||
|
||||||
|
!/\d/.test(password) ||
|
||||||
|
!/[a-z]/.test(password) ||
|
||||||
|
!/[A-Z]/.test(password)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
@@ -1,94 +1,90 @@
|
|||||||
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
|
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
|
||||||
|
import { alphabet } from 'oslo/crypto';
|
||||||
|
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
|
||||||
import { UseSend } from 'usesend-js';
|
import { UseSend } from 'usesend-js';
|
||||||
|
|
||||||
/** @todo Document this */
|
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
||||||
export const Usesend = (config: EmailUserConfig): EmailConfig => {
|
|
||||||
return {
|
return {
|
||||||
id: 'usesend',
|
id: 'usesend',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
name: 'Usesend',
|
name: 'UseSend',
|
||||||
from: 'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
|
from: 'TechTracker <admin@techtracker.gbrown.org>',
|
||||||
maxAge: 24 * 60 * 60,
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
|
|
||||||
|
async generateVerificationToken() {
|
||||||
|
const random: RandomReader = {
|
||||||
|
read(bytes) {
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return generateRandomString(random, alphabet('0-9'), 6);
|
||||||
|
},
|
||||||
|
|
||||||
async sendVerificationRequest(params) {
|
async sendVerificationRequest(params) {
|
||||||
const { identifier: to, provider, url, theme } = params;
|
const { identifier: to, provider, url, theme, token } = params;
|
||||||
const { host } = new URL(url);
|
//const { host } = new URL(url);
|
||||||
|
const host = 'TechTracker';
|
||||||
|
|
||||||
const useSend = new UseSend(
|
const useSend = new UseSend(
|
||||||
provider.apiKey,
|
process.env.AUTH_USESEND_API_KEY!,
|
||||||
'https://usesend.gbrown.org',
|
'https://usesend.gbrown.org',
|
||||||
);
|
);
|
||||||
const { error } = await useSend.emails.send({
|
|
||||||
to,
|
// For password reset, we want to send the code, not the magic link
|
||||||
from:
|
const isPasswordReset =
|
||||||
provider.from ??
|
url.includes('reset') || provider.id?.includes('reset');
|
||||||
'TechTracker Admin <admin@mail.techtracker.gbrown.org>',
|
|
||||||
subject: `Sign in to ${host}`,
|
const result = await useSend.emails.send({
|
||||||
html: html({ url, host, theme }),
|
from: provider.from!,
|
||||||
text: text({ url, host }),
|
to: [to],
|
||||||
|
subject: isPasswordReset
|
||||||
|
? `Reset your password - ${host}`
|
||||||
|
: `Sign in to ${host}`,
|
||||||
|
text: isPasswordReset
|
||||||
|
? `Your password reset code is ${token}`
|
||||||
|
: `Your sign in code is ${token}`,
|
||||||
|
html: isPasswordReset
|
||||||
|
? `
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||||
|
<h2>Password Reset Request</h2>
|
||||||
|
<p>You requested a password reset. Your reset code is:</p>
|
||||||
|
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
|
||||||
|
${token}
|
||||||
|
</div>
|
||||||
|
<p>This code expires in 1 hour.</p>
|
||||||
|
<p>If you didn't request this, please ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||||
|
<h2>Your Sign In Code</h2>
|
||||||
|
<p>Your verification code is:</p>
|
||||||
|
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
|
||||||
|
${token}
|
||||||
|
</div>
|
||||||
|
<p>This code expires in 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Usesend error: ' + error.message);
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error('UseSend error: ' + JSON.stringify(result.error));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
options: config,
|
options: config,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
type Theme = {
|
// Create specific instances for password reset and email verification
|
||||||
colorScheme?: 'auto' | 'dark' | 'light';
|
export const UseSendOTPPasswordReset = UseSendProvider({
|
||||||
logo?: string;
|
id: 'usesend-otp-password-reset',
|
||||||
brandColor?: string;
|
apiKey: process.env.AUTH_USESEND_API_KEY,
|
||||||
buttonText?: string;
|
maxAge: 60 * 60, // 1 hour
|
||||||
};
|
});
|
||||||
|
|
||||||
const text = ({ url, host }: { url: string; host: string }) => {
|
export const UseSendOTP = UseSendProvider({
|
||||||
return `Sign in to ${host}\n${url}\n\n`;
|
id: 'usesend-otp',
|
||||||
};
|
apiKey: process.env.AUTH_USESEND_API_KEY,
|
||||||
|
maxAge: 60 * 20, // 20 minutes
|
||||||
const html = (params: { url: string; host: string; theme: Theme }) => {
|
});
|
||||||
const { url, host, theme } = params;
|
|
||||||
|
|
||||||
const escapedHost = host.replace(/\./g, '​.');
|
|
||||||
|
|
||||||
const brandColor = theme.brandColor || '#346df1';
|
|
||||||
|
|
||||||
const buttonText = theme.buttonText || '#fff';
|
|
||||||
|
|
||||||
const color = {
|
|
||||||
background: '#f9f9f9',
|
|
||||||
text: '#444',
|
|
||||||
mainBackground: '#fff',
|
|
||||||
buttonBackground: brandColor,
|
|
||||||
buttonBorder: brandColor,
|
|
||||||
buttonText,
|
|
||||||
};
|
|
||||||
|
|
||||||
return `
|
|
||||||
<body style="background: ${color.background};">
|
|
||||||
<table width="100%" border="0" cellspacing="20" cellpadding="0"
|
|
||||||
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center"
|
|
||||||
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
|
||||||
Sign in to <strong>${escapedHost}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding: 20px 0;">
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
|
|
||||||
target="_blank"
|
|
||||||
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
|
|
||||||
in</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"
|
|
||||||
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
|
|
||||||
If you did not request this email you can safely ignore it.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
@@ -15,3 +15,5 @@ AUTH_MICROSOFT_ENTRA_ID_ID=
|
|||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
|
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
|
||||||
AUTH_MICROSOFT_ENTRA_ID_AUTH_URL=
|
AUTH_MICROSOFT_ENTRA_ID_AUTH_URL=
|
||||||
|
# UseSend
|
||||||
|
AUTH_USESEND_API_KEY=
|
||||||
|
@@ -12,10 +12,15 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
|
"@react-email/components": "0.5.4",
|
||||||
|
"@react-email/render": "^1.3.0",
|
||||||
"convex": "^1.27.3",
|
"convex": "^1.27.3",
|
||||||
|
"react": "19.1.1",
|
||||||
|
"react-dom": "19.1.1",
|
||||||
"usesend-js": "^1.5.2"
|
"usesend-js": "^1.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"react-email": "4.2.11",
|
||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user