feat: add authentication feature

This commit is contained in:
2025-07-06 12:33:48 +07:00
parent 7b17de88c4
commit eaae04ecc8
17 changed files with 500 additions and 94 deletions
+1
View File
@@ -0,0 +1 @@
export const AUTH_COOKIE = 'payload-token';
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(3),
username: z.string()
.min(3, 'Username must be at lease 3 characters')
.max(63, 'Username must be less than 63 characters')
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
'Username can only contain lowercase letters, numbers and hyphens. It must start and end a letter or number'
).refine(
(val) => !val.includes('--'),
'Username cannot contain consecutive hyphens'
)
.transform((val) => val.toLowerCase()),
});
export type RegisterSchema = z.infer<typeof registerSchema>;
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export type LoginSchema = z.infer<typeof loginSchema>
+101
View File
@@ -0,0 +1,101 @@
import { headers as getHeaders, cookies as getCookies } from "next/headers";
import { TRPCError } from "@trpc/server";
import { AUTH_COOKIE } from "@/modules/auth/constants";
import { loginSchema, registerSchema } from "@/modules/auth/schemas";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
export const authRouter = createTRPCRouter({
session: baseProcedure.query(async ({ ctx }) => {
const headers = await getHeaders();
return await ctx.db.auth({ headers });
}),
logout: baseProcedure.mutation((async () => {
const cookies = await getCookies();
cookies.delete(AUTH_COOKIE);
})),
register: baseProcedure
.input(
registerSchema
)
.mutation(async ({ input, ctx }) => {
const existingData = await ctx.db.find({
collection: 'users',
limit: 1,
where: {
username: { equals: input.username }
}
})
const existingUser = existingData.docs[0];
if (existingUser) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Username already taken',
})
}
await ctx.db.create({
collection: 'users',
data: {
email: input.email,
username: input.username,
password: input.password,
}
})
const data = await ctx.db.login({
collection: 'users',
data: {
email: input.email,
password: input.password,
}
})
if (!data.token) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Failed to login',
})
}
const cookies = await getCookies();
cookies.set({
name: AUTH_COOKIE,
value: data.token,
httpOnly: true,
path: '/',
});
}),
login: baseProcedure
.input(loginSchema)
.mutation(async ({ input, ctx }) => {
const data = await ctx.db.login({
collection: 'users',
data: {
email: input.email,
password: input.password,
}
})
if (!data.token) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Failed to login',
})
}
const cookies = await getCookies();
cookies.set({
name: AUTH_COOKIE,
value: data.token,
httpOnly: true,
path: '/',
});
return data;
})
})
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { LoginSchema, loginSchema } from "@/modules/auth/schemas";
import { useTRPC } from "@/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { Poppins } from "next/font/google";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
const poppins = Poppins({
subsets: ['latin'],
weight: ['700']
})
export const SignInView = () => {
const router = useRouter();
const trpc = useTRPC();
const login = useMutation(trpc.auth.login.mutationOptions({
onError: (error) => {
toast.error(error.message)
},
onSuccess: () => {
router.push('/');
}
}));
const form = useForm<LoginSchema>({
mode: 'all',
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
}
})
const onSubmit = (values: LoginSchema) => {
login.mutate(values);
}
return (
<div className='grid grid-cols-1 lg:grid-cols-5'>
<div className='bg-[#F4F4F0] h-screen w-full lg:col-span-3 overflow-y-auto'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='flex flex-col gap-8 p-4 lg:p-16'>
<div className='flex items-center justify-between mb-8'>
<Link href='/'>
<span className={cn('text-2xl font-semibold', poppins.className)}>funroad</span>
</Link>
<Button asChild variant='ghost' size='sm' className='text-base border-none underline'>
<Link prefetch href='/sign-up'>
Sign up
</Link>
</Button>
</div>
<h1 className='text-4xl font-medium'>
Welcome back to Funroad.
</h1>
<FormField
name='email' render={({ field }) => (
<FormItem>
<FormLabel className='text-base'>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name='password' render={({ field }) => (
<FormItem>
<FormLabel className='text-base'>Password</FormLabel>
<FormControl>
<Input {...field} type='password' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit' size='lg' variant='elevated'
className='bg-black text-white hover:bg-pink-400 hover:text-primary'
disabled={login.isPending}
>Log in</Button>
</form>
</Form>
</div>
<div
className='h-screen w-full lg:col-span-2 hidden lg:block'
style={{ backgroundImage: "url('/auth-bg.png')", backgroundSize: 'cover', backgroundPosition: 'center' }}
/>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { registerSchema, RegisterSchema } from "@/modules/auth/schemas";
import { useTRPC } from "@/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { Poppins } from "next/font/google";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
const poppins = Poppins({
subsets: ['latin'],
weight: ['700']
})
export const SignUpView = () => {
const router = useRouter();
const trpc = useTRPC();
const register = useMutation(trpc.auth.register.mutationOptions({
onError: (error) => {
toast.error(error.message)
},
onSuccess: () => {
router.push('/');
}
}));
const form = useForm<RegisterSchema>({
mode: 'all',
resolver: zodResolver(registerSchema),
defaultValues: {
email: '',
password: '',
username: '',
}
})
const onSubmit = (values: RegisterSchema) => {
register.mutate(values);
}
const username = form.watch('username');
const usernameErrors = form.formState.errors.username;
const showPreview = username && !usernameErrors;
return (
<div className='grid grid-cols-1 lg:grid-cols-5'>
<div className='bg-[#F4F4F0] h-screen w-full lg:col-span-3 overflow-y-auto'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='flex flex-col gap-8 p-4 lg:p-16'>
<div className='flex items-center justify-between mb-8'>
<Link href='/'>
<span className={cn('text-2xl font-semibold', poppins.className)}>funroad</span>
</Link>
<Button asChild variant='ghost' size='sm' className='text-base border-none underline'>
<Link prefetch href='/sign-in'>
Sign in
</Link>
</Button>
</div>
<h1 className='text-4xl font-medium'>
Join over 1,580 creators earning money on Funroad.
</h1>
<FormField
name='username' render={({ field }) => (
<FormItem>
<FormLabel className='text-base'>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription className={cn('hidden', showPreview && 'block')}>
Your store will be available at&nbsp;
{/* TODO: Use proper method to generate the preview url */}
<strong>{username}</strong>.shop.com
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name='email' render={({ field }) => (
<FormItem>
<FormLabel className='text-base'>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name='password' render={({ field }) => (
<FormItem>
<FormLabel className='text-base'>Password</FormLabel>
<FormControl>
<Input {...field} type='password' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit' size='lg' variant='elevated'
className='bg-black text-white hover:bg-pink-400 hover:text-primary'
disabled={register.isPending}
>Create account</Button>
</form>
</Form>
</div>
<div
className='h-screen w-full lg:col-span-2 hidden lg:block'
style={{ backgroundImage: "url('/auth-bg.png')", backgroundSize: 'cover', backgroundPosition: 'center' }}
/>
</div>
)
}