mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 20:01:36 +00:00
feat: add authentication feature
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const AUTH_COOKIE = 'payload-token';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user