Merge pull request #6 from Nezumi-2711/feat/impl-authentication-feature

Feature/ Add authentication for the web app
This commit is contained in:
2025-07-06 12:40:40 +07:00
committed by GitHub
17 changed files with 500 additions and 94 deletions
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# idea
.idea
+1
View File
@@ -1,6 +1,7 @@
{
"name": "multitenant-ecommerce",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"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;
+78 -78
View File
@@ -12,109 +12,109 @@ import { Button } from '@/components/ui/button';
import NavbarSidebar from './navbar-sidebar';
const poppins = Poppins({
subsets: ['latin'],
weight: ['700'],
subsets: ['latin'],
weight: ['700'],
});
interface NavbarItemProps {
href: string;
children: ReactNode;
isActive?: boolean;
href: string;
children: ReactNode;
isActive?: boolean;
}
const navbarItems = [
{
href: '/',
children: 'Home',
},
{
href: '/about',
children: 'About',
},
{
href: '/features',
children: 'Features',
},
{
href: '/pricing',
children: 'Pricing',
},
{
href: '/contact',
children: 'Contact',
},
{
href: '/',
children: 'Home',
},
{
href: '/about',
children: 'About',
},
{
href: '/features',
children: 'Features',
},
{
href: '/pricing',
children: 'Pricing',
},
{
href: '/contact',
children: 'Contact',
},
];
const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => {
return (
<Button
asChild
variant="outline"
className={cn(
'bg-transparent hover:bg-transparent rounded-full hover:border-primary border-transparent px-3.5 text-lg',
isActive && 'bg-black text-white hover:bg-black hover:text-white'
)}
>
<Link href={href}>{children}</Link>
</Button>
);
return (
<Button
asChild
variant="outline"
className={cn(
'bg-transparent hover:bg-transparent rounded-full hover:border-primary border-transparent px-3.5 text-lg',
isActive && 'bg-black text-white hover:bg-black hover:text-white'
)}
>
<Link href={href}>{children}</Link>
</Button>
);
};
const Navbar = () => {
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<nav className="h-20 flex border-b justify-between font-medium bg-white">
<Link href="/" className="pl-6 flex items-center">
return (
<nav className="h-20 flex border-b justify-between font-medium bg-white">
<Link href="/" className="pl-6 flex items-center">
<span className={cn('text-5xl font-semibold', poppins.className)}>
funroad
</span>
</Link>
</Link>
<NavbarSidebar
items={navbarItems}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<NavbarSidebar
items={navbarItems}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<div className="items-center gap-4 hidden lg:flex">
{navbarItems.map((item) => (
<NavbarItem
key={item.href}
href={item.href}
isActive={pathname === item.href}
>
{item.children}
</NavbarItem>
))}
</div>
<div className="items-center gap-4 hidden lg:flex">
{navbarItems.map((item) => (
<NavbarItem
key={item.href}
href={item.href}
isActive={pathname === item.href}
>
{item.children}
</NavbarItem>
))}
</div>
<div className="hidden lg:flex">
<Button
asChild
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"
>
<Link href="/sign-in">Log in</Link>
</Button>
<div className="hidden lg:flex">
<Button
asChild
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"
>
<Link prefetch href="/sign-in">Log in</Link>
</Button>
<Button
asChild
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"
>
<Link href="/sign-up">Start Selling</Link>
</Button>
</div>
<Button
asChild
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"
>
<Link prefetch href="/sign-up">Start Selling</Link>
</Button>
</div>
<div className='flex lg:hidden items-center justify-center'>
<Button variant="ghost" className="size-12 border-transparent bg-white" onClick={() => setIsSidebarOpen(true)}>
<MenuIcon />
<MenuIcon/>
</Button>
</div>
</nav>
);
</nav>
);
};
export default Navbar;
+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() {
return <div>Home page</div>;
const trpc = useTRPC();
const { data } = useQuery(trpc.auth.session.queryOptions());
return <div>{JSON.stringify(data?.user, null, 2)}</div>;
}
+19 -12
View File
@@ -1,29 +1,36 @@
import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google';
import { ReactNode } from "react";
import { TRPCReactProvider } from '@/trpc/client';
// Components
import { Toaster } from "@/components/ui/sonner";
import './globals.css';
const dmSans = DM_Sans({
subsets: ['latin'],
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
<html lang="en">
<body className={`${dmSans.className} antialiased`}>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
return (
<html lang='en'>
<body className={`${dmSans.className} antialiased`}>
<TRPCReactProvider>
{children}
<Toaster />
</TRPCReactProvider>
</body>
</html>
);
}
+6 -2
View File
@@ -7,7 +7,11 @@ export const Users: CollectionConfig = {
},
auth: true,
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 {
id: string;
username: string;
updatedAt: string;
createdAt: string;
email: string;
@@ -239,6 +240,7 @@ export interface PayloadMigration {
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
username?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
+1 -1
View File
@@ -7,7 +7,7 @@ import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Users } from './collections/Users';
import { Media } from './collections/Media'
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 { createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({
auth: authRouter,
categories: categoriesRouter,
});
// export type definition of API