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
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# idea
.idea
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "multitenant-ecommerce", "name": "multitenant-ecommerce",
"version": "0.1.0", "version": "0.1.0",
"type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

+7
View File
@@ -0,0 +1,7 @@
import { SignInView } from "@/modules/auth/ui/views/sign-in-view";
const Page = () => {
return <SignInView />
}
export default Page;
+7
View File
@@ -0,0 +1,7 @@
import { SignUpView } from "@/modules/auth/ui/views/sign-up-view";
const Page = () => {
return <SignUpView />
}
export default Page;
+3 -3
View File
@@ -96,7 +96,7 @@ const Navbar = () => {
variant="secondary" variant="secondary"
className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-white hover:bg-pink-400 transition-colors text-lg" className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-white hover:bg-pink-400 transition-colors text-lg"
> >
<Link href="/sign-in">Log in</Link> <Link prefetch href="/sign-in">Log in</Link>
</Button> </Button>
<Button <Button
@@ -104,13 +104,13 @@ const Navbar = () => {
variant="secondary" variant="secondary"
className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg" className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg"
> >
<Link href="/sign-up">Start Selling</Link> <Link prefetch href="/sign-up">Start Selling</Link>
</Button> </Button>
</div> </div>
<div className='flex lg:hidden items-center justify-center'> <div className='flex lg:hidden items-center justify-center'>
<Button variant="ghost" className="size-12 border-transparent bg-white" onClick={() => setIsSidebarOpen(true)}> <Button variant="ghost" className="size-12 border-transparent bg-white" onClick={() => setIsSidebarOpen(true)}>
<MenuIcon /> <MenuIcon/>
</Button> </Button>
</div> </div>
</nav> </nav>
+10 -1
View File
@@ -1,3 +1,12 @@
'use client';
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
export default function Home() { export default function Home() {
return <div>Home page</div>; const trpc = useTRPC();
const { data } = useQuery(trpc.auth.session.queryOptions());
return <div>{JSON.stringify(data?.user, null, 2)}</div>;
} }
+10 -3
View File
@@ -1,8 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google'; import { DM_Sans } from 'next/font/google';
import { ReactNode } from "react";
import { TRPCReactProvider } from '@/trpc/client'; import { TRPCReactProvider } from '@/trpc/client';
// Components
import { Toaster } from "@/components/ui/sonner";
import './globals.css'; import './globals.css';
const dmSans = DM_Sans({ const dmSans = DM_Sans({
@@ -17,12 +21,15 @@ export const metadata: Metadata = {
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang='en'>
<body className={`${dmSans.className} antialiased`}> <body className={`${dmSans.className} antialiased`}>
<TRPCReactProvider>{children}</TRPCReactProvider> <TRPCReactProvider>
{children}
<Toaster />
</TRPCReactProvider>
</body> </body>
</html> </html>
); );
+6 -2
View File
@@ -7,7 +7,11 @@ export const Users: CollectionConfig = {
}, },
auth: true, auth: true,
fields: [ fields: [
// Email added by default {
// Add more fields as needed name: 'username',
required: true,
unique: true,
type: 'text'
}
], ],
} }
+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>
)
}
+2
View File
@@ -125,6 +125,7 @@ export interface UserAuthOperations {
*/ */
export interface User { export interface User {
id: string; id: string;
username: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -239,6 +240,7 @@ export interface PayloadMigration {
* via the `definition` "users_select". * via the `definition` "users_select".
*/ */
export interface UsersSelect<T extends boolean = true> { export interface UsersSelect<T extends boolean = true> {
username?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;
+1 -1
View File
@@ -7,7 +7,7 @@ import { buildConfig } from 'payload'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import sharp from 'sharp' import sharp from 'sharp'
import { Users } from './collections/Users' import { Users } from './collections/Users';
import { Media } from './collections/Media' import { Media } from './collections/Media'
import { Categories } from './collections/Categories' import { Categories } from './collections/Categories'
+4
View File
@@ -1,7 +1,11 @@
import { authRouter } from "@/modules/auth/server/procedures";
import { categoriesRouter } from '@/modules/categories/server/procedures'; import { categoriesRouter } from '@/modules/categories/server/procedures';
import { createTRPCRouter } from '../init'; import { createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
auth: authRouter,
categories: categoriesRouter, categories: categoriesRouter,
}); });
// export type definition of API // export type definition of API