Update login stuff

This commit is contained in:
2025-08-29 16:41:24 -05:00
parent 6e892a8614
commit 1d32d31550
16 changed files with 1155 additions and 86 deletions

View File

@@ -1,71 +1,328 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { ConvexError } from 'convex/values';
import { useState } from 'react';
import {
Card,
CardContent,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
StatusMessage,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui';
const signInFormSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string()
.min(8, {
message: 'Password must be at least 8 characters.',
})
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, {
message: 'Password must contain at least one digit, ' +
'one uppercase letter, one lowercase letter, ' +
'and one special character.'
})
});
const signUpFormSchema = z.object({
name: z.string()
.min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string()
.min(8, {
message: 'Password must be at least 8 characters.',
})
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, {
message: 'Password must contain at least one digit, ' +
'one uppercase letter, one lowercase letter, ' +
'and one special character.'
}),
confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
export default function SignIn() {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
const [error, setError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: { email: '', password: '' },
});
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
try {
setLoading(true);
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
if (flow === 'signUp') {
formData.append('name', values.name);
if (values.confirmPassword !== values.password)
throw new ConvexError({message: 'Passwords do not match!'});
}
await signIn('password', formData);
signInForm.reset();
router.push('/');
} catch (error) {
setStatusMessage(`Error signing in: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setLoading(false);
}
};
return (
<div className='flex flex-col gap-8 w-96 mx-auto h-screen justify-center items-center'>
<p>Log in to see the numbers</p>
<form
className='flex flex-col gap-2'
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
formData.set('flow', flow);
void signIn('password', formData)
.catch((error) => {
setError(error.message);
})
.then(() => {
router.push('/');
});
}}
>
<input
className='bg-background text-foreground rounded-md p-2 border-2 border-slate-200 dark:border-slate-800'
type='email'
name='email'
placeholder='Email'
/>
<input
className='bg-background text-foreground rounded-md p-2 border-2 border-slate-200 dark:border-slate-800'
type='password'
name='password'
placeholder='Password'
/>
<button
className='bg-foreground text-background rounded-md'
type='submit'
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center'
>
{flow === 'signIn' ? 'Sign in' : 'Sign up'}
</button>
<div className='flex flex-row gap-2'>
<span>
{flow === 'signIn'
? "Don't have an account?"
: 'Already have an account?'}
</span>
<span
className='text-foreground underline hover:no-underline cursor-pointer'
onClick={() => setFlow(flow === 'signIn' ? 'signUp' : 'signIn')}
>
{flow === 'signIn' ? 'Sign up instead' : 'Sign in instead'}
</span>
</div>
{error && (
<div className='bg-red-500/20 border-2 border-red-500/50 rounded-md p-2'>
<p className='text-foreground font-mono text-xs'>
Error signing in: {error}
</p>
</div>
)}
</form>
<TabsList className='py-6'>
<TabsTrigger
value='signIn'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent value='signIn'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signInForm}>
<form
onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signInForm.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>
)}
/>
<FormField
control={signInForm.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-xl'>
Password
</FormLabel>
<Link href='/forgot-password'>Forgot Password?</Link>
</div>
<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>
)}
/>
{statusMessage && (
<StatusMessage
message={
statusMessage.toLowerCase().includes('error')
? { error: statusMessage }
: { success: statusMessage }
}
/>
)}
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign In
</SubmitButton>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='signUp'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signUpForm.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Name
</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.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>
)}
/>
<FormField
control={signUpForm.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
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={signUpForm.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>
)}
/>
{statusMessage && (
<StatusMessage
message={
statusMessage.toLowerCase().includes('error')
? { error: statusMessage }
: { success: statusMessage }
}
/>
)}
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign Up
</SubmitButton>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</Card>
</div>
);
}
};

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

167
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1 +1,31 @@
export { Button, buttonVariants } from './button';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
} from './card';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} from './form';
export { Input } from './input';
export { Label } from './label';
export { Separator } from './separator';
export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button';
export {
Tabs,
TabsList,
TabsTrigger,
TabsContent
} from './tabs';

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,55 @@
import { type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
type Message = { success: string } | { error: string } | { message: string };
type StatusMessageProps = {
message: Message;
containerProps?: ComponentProps<'div'>;
textProps?: ComponentProps<'div'>;
};
export const StatusMessage = ({
message,
containerProps,
textProps,
}: StatusMessageProps) => {
return (
<div className='flex flex-col items-center w-full'>
{'success' in message && (
<div {...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'dark:bg-green-500/20 bg-green-700/20 border-2',
'dark:border-green-500/50 border-green-700/50',
containerProps?.className,
)}
>
<p {...textProps}>{message.success}</p>
</div>
)}
{'error' in message && (
<div {...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'bg-destructive/20 border-2 border-destructive/80',
containerProps?.className,
)}
>
<p {...textProps}>{message.error}</p>
</div>
)}
{'message' in message && (
<div {...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'bg-accent/20 border-2 border-primary/80',
containerProps?.className,
)}
>
<p {...textProps}>{message.message}</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,50 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SubmitButtonProps = Omit<
ComponentProps<typeof Button>,
'type' | 'aria-disabled'
> & {
pendingText?: string;
pendingTextProps?: ComponentProps<'p'>;
loaderProps?: ComponentProps<typeof Loader2>;
};
export const SubmitButton = ({
children,
className,
pendingText = 'Submitting...',
pendingTextProps,
loaderProps,
...props
}: SubmitButtonProps) => {
const { pending } = useFormStatus();
return (
<Button
type='submit'
aria-disabled={pending}
{...props}
className={cn('cursor-pointer', className)}
>
{pending || props.disabled ? (
<>
<Loader2
{...loaderProps}
className={cn('mr-2 h-4 w-4 animate-spin', loaderProps?.className)}
/>
<p {...pendingTextProps}
className={cn('text-sm font-medium', pendingTextProps?.className)}
>
{pendingText}
</p>
</>
) : (
children
)}
</Button>
);
};

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }