使用Next.js、TypeORM和Shadcn-UI从零搭建现代化SaaS系统(完成登录和框架)
176
2024-06-28 19:06
5 个月前
这个章节我们完成整个框架的搭建
分析缺少了什么东西
- 我们缺少登录页
- 我们缺少验证码发送逻辑
- 我们缺少了工作台页面
我们先做一下登录页
首先我们需要做一个登录页用的layout
// client/src/components/SlimLayout.tsx
import Image from 'next/image';
import backgroundImage from '@/images/background-auth.jpg';
export function SlimLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="relative flex min-h-full shrink-0 justify-center md:px-12 lg:px-0 flex-1">
<div className="relative z-10 flex flex-1 flex-col bg-white px-4 py-10 shadow-2xl sm:justify-center md:flex-none md:px-28">
<main className="mx-auto w-full max-w-md sm:px-4 md:w-96 md:max-w-sm md:px-0">{children}</main>
</div>
<div className="hidden sm:contents lg:relative lg:block lg:flex-1">
<Image className="absolute inset-0 h-full w-full object-cover" src={backgroundImage} alt="" unoptimized />
</div>
</div>
</>
);
}
#添加react-hook-form
pnpm add react-hook-form zod
// client/src/schema/index.ts
// 添加我们的schema
import { z } from 'zod';
export const loginFormSchema = z.object({
email: z.string().email({
message: '请填写正确的邮箱'
}),
code: z
.string()
.min(1, {
message: '请输入验证码'
})
.min(6, {
message: '请输入六位验证码'
})
.max(6, {
message: '请输入六位验证码'
})
});
export const emailCodeSchema = z.object({
email: z.string().email({
message: '请填写正确的邮箱'
})
});
增加useCheckLogin和useCountDown和useMounted
这两个hooks一个是检测是否登录,一个是发送邮件验证码后,倒计时的,一个是判断客户端的
'use client';
import { useAppStore } from '@/components/providers/appStoreProvider';
import { useEffect } from 'react';
export const useCheckLogin = (callBack?: (isLogin: boolean) => void) => {
const [isLogin] = useAppStore(state => [state.isLogin]);
useEffect(() => {
if (isLogin !== undefined) {
callBack && callBack(isLogin);
}
}, [isLogin]);
return {
isLogin
};
};
import { useEffect, useState } from 'react';
export const useCountDown = () => {
const [count, setCount] = useState<number>(0);
useEffect(() => {
let timer: any;
if (count > 0) {
timer = setTimeout(() => {
setCount(count - 1);
}, 1000);
}
return () => {
timer && clearTimeout(timer);
};
}, [count]);
return {
count,
setCount,
};
};
import { useEffect, useState } from 'react';
export const useMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return [mounted];
};
// apps/client/src/app/(auth)/_components/loginView.tsx
'use client';
import { useForm } from 'react-hook-form';
import { SlimLayout } from '@/components/SlimLayout';
import { Logo } from '@/components/Logo';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginFormSchema } from '@/schema';
import { trpc } from '@/app/_trpc/index';
import { Loader } from 'lucide-react';
import { useCheckLogin } from '@/hooks/useCheckLogin';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useCountDown } from '@/hooks/useCountDown';
import { useInitContext } from '@/components/providers/initProvider';
export const LoginView = () => {
const router = useRouter();
const initContext = useInitContext();
const { isLogin } = useCheckLogin(isLogin => {
if (isLogin) {
router.replace('/home');
}
});
const { count, setCount } = useCountDown();
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: '',
code: ''
}
});
const loginMutation = trpc.login.useMutation({
onSuccess: async ctx => {
if (ctx.success) {
const url = new URL(window.location.href);
localStorage.setItem('accessToken', ctx.data.accessToken);
await initContext.refreshLogin();
router.replace(url.searchParams.get('redirect') || '/');
}
},
onError: (error, variables, context) => {
toast(error.message);
}
});
const getCodeMutation = trpc.getCode.useMutation({
onSuccess: ctx => {
if (ctx.success) {
setCount(60);
}
},
onError: (error, variables, context) => {
toast(error.message);
}
});
const onSubmit = async (values: z.infer<typeof loginFormSchema>) => {
void loginMutation.mutate({
...values
});
};
if (isLogin === undefined || isLogin) return null;
return (
<SlimLayout>
<div className="flex">
<Logo />
</div>
<h2 className="mt-20 text-lg font-semibold text-gray-900">登陆你的账号</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-10 grid grid-cols-1 gap-y-8">
<FormField
control={form.control}
render={({ field }) => {
return (
<FormItem className={'grid gap-2 relative'}>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input placeholder={'请输入邮箱'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
name={'email'}
/>
<FormField
control={form.control}
render={({ field }) => {
return (
<FormItem className={'grid gap-2'}>
<FormLabel>验证码</FormLabel>
<div className={'relative'}>
<FormControl className={'pr-32'}>
<Input placeholder={'请输入验证码'} maxLength={6} {...field} />
</FormControl>
<Button
variant={'ghost'}
type={'button'}
disabled={count > 0}
onClick={() => {
if (count > 0) return;
getCodeMutation.mutate({
email: form.getValues()['email']
});
}}
className={'absolute top-0 right-0 w-28 px-0'}>
{getCodeMutation.isPending && <Loader className={'animate-spin'}></Loader>}
{count == 0 ? '获取验证码' : `${count}S`}
</Button>
</div>
<FormMessage />
</FormItem>
);
}}
name={'code'}
/>
<Button type="submit" variant="default" className="w-full">
{loginMutation.isPending && <Loader className={'animate-spin'}></Loader>}
<span>
登录 <span aria-hidden="true">→</span>
</span>
</Button>
</form>
</Form>
</SlimLayout>
);
};
我们现在来完成trpc的接口
使用middleware来完成需要登录接口的保护
我们的登录没有使用cookie做session,我们是使用localstorage来保存token的。
获取用户信息,使用jsonwebtoken,我们这里需要使用到jose库
const secretKey = process.env.jwtSecretKey;
const key = new TextEncoder().encode(secretKey);
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60; // 30 days
export async function encrypt(payload: any, maxAge = DEFAULT_MAX_AGE) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(Date.now() + maxAge)
.sign(key);
}
export async function decrypt<T>(input: string): Promise<T> {
const { payload } = await jwtVerify(input, key, {
algorithms: ['HS256']
});
return payload as T;
}
import { headers } from 'next/headers';
import { entitiesTypesMap } from '@sass-startup/db';
import {Db} from "@/db";
import {decrypt} from "@/lib/utils";
export const getUser = async () => {
try {
const headersList = headers();
const authorization = headersList.get('authorization') || '';
let user: entitiesTypesMap<'SassStartUserEntity'> | null = null;
if (authorization !== '') {
const token = authorization.split(' ')[1] || '';
if (token !== '') {
// 解码得到user
const userDe = await decrypt<{ user: entitiesTypesMap<'SassStartUserEntity'> }>(token);
const db = await Db.install;
user = await db.getRepository('SassStartUserEntity').findOne({
where: {
id: userDe.user.id
}
});
}
}
return user;
} catch (e) {
return null;
}
};
修改之前的trpc,增加一个中间件,获取用户,没有则返回401
import {initTRPC, TRPCError} from '@trpc/server';
import {getUser} from "@/server";
const t = initTRPC.create();
const middleware = t.middleware;
const isAuth = middleware(async opts => {
const user = await getUser();
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED'
});
}
return opts.next({
ctx: {
user: user
}
});
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const privateProcedure = t.procedure.use(isAuth);
登录接口,发送验证码,用户信息接口
import {privateProcedure, publicProcedure, router} from './trpc';
import {emailCodeSchema, loginFormSchema} from "@/schema";
import {RedisDb} from "@/db/redis";
import {IResponse} from "@/lib/res";
import {generateRandomStr,encrypt} from "@/lib/utils";
import nodemailer from 'nodemailer';
import {Db} from "@/db";
import {TRPCError} from "@trpc/server";
import {CustomBaseException} from "@/lib/error";
const transporter = nodemailer.createTransport({
service: 'qq',
host: 'smtp.qq.com',
auth: {
user: '424139777@qq.com',
pass: '****'
}
});
export const appRouter = router({
// 登录
login:publicProcedure.input(loginFormSchema).mutation(async ({ctx,input})=>{
const { email, code } = input;
const db = await Db.install;
const redis = await RedisDb.install;
const key = `${email}__${code}`;
const hasCode = await redis.install.get(key);
if (!hasCode) {
throw new TRPCError({
code: 'BAD_REQUEST',
cause: new CustomBaseException(
{
message: '验证码错误,请重新输入',
meta: {
code: 40001,
data: 'tete'
}
},
400
)
});
}
// 删除
void redis.install.del(key);
// 判断是否存在
let user = await db.getRepository('SassStartUserEntity').findOne({
where: {
email: email
},
withDeleted: true
});
if (user === null) {
user = await db.getRepository('SassStartUserEntity').save({
email: email,
password: 'test'
});
}
user.password = null as unknown as any;
return IResponse.ok({
accessToken: await encrypt({
user
})
});
}),
// 发送邮箱验证码
getCode: publicProcedure.input(emailCodeSchema).mutation(async ({ ctx, input }) => {
const { email } = input;
const redis = await RedisDb.install;
const code = generateRandomStr(10, 6);
await redis.install.setEx(`${email}__${code}`, code, 60 * 5);
const mail = await transporter.sendMail({
from: '424139777@qq.com',
to: email,
subject: `Website activity from ${email}`,
html: `
<p>你的验证码是: ${code} </p>
`
});
transporter.close();
return IResponse.ok({});
}),
//获取登录用户信息
getUser: privateProcedure.query(async ({ ctx }) => {
return {
user: ctx.user
};
})
});
export type AppRouter = typeof appRouter;
添加初始化的provider,获取用户信息
'use client';
import { FC, PropsWithChildren, useEffect, createContext, useContext, useCallback } from 'react';
import { useAppStore } from '@/components/providers/appStoreProvider';
import { trpc } from '@/app/_trpc/index';
import { useMounted } from '@/hooks/useMounted';
import { useRouter } from 'next/navigation';
const initContext = createContext<{
refreshLogin: () => Promise<void>;
logOut: () => void;
}>({} as any);
type InitProviderProps = {} & PropsWithChildren;
export const InitProvider: FC<InitProviderProps> = ({ children }) => {
const router = useRouter();
const [, setUserProfile] = useAppStore(state => [state.isLogin, state.setUserProfile]);
const [mounted] = useMounted();
const { data, isError, refetch } = trpc.getUser.useQuery(undefined, {
retry: 0,
enabled: mounted
});
const refreshLogin = async () => {
const c = await refetch();
if (c.data) {
setUserProfile(true, {
email: c.data.user.email,
nickName: c.data.user.nickName,
});
}
};
useEffect(() => {
const listenStorage = async (event: StorageEvent) => {
if (event.key === 'accessToken') {
const c = await refetch();
}
};
addEventListener('storage', listenStorage);
return () => {
removeEventListener('storage', listenStorage);
};
}, []);
const logOut = useCallback(() => {
localStorage.removeItem('accessToken');
setUserProfile(false, {});
}, []);
useEffect(() => {
if (mounted) {
if (isError) {
setUserProfile(false);
} else if (data) {
console.log(data);
setUserProfile(true, {
email: data.user.email,
nickName: data.user.nickName,
});
}
}
}, [mounted, data, isError]);
return (
<initContext.Provider
value={{
refreshLogin: refreshLogin,
logOut: logOut
}}>
{children}
</initContext.Provider>
);
};
export const useInitContext = () => {
return useContext(initContext);
};
我们就会看到服务端发起请求,点击跳转到登录页
添加一个按钮这个会判断登录状态会render不一样的组件
'use client';
import { useCheckLogin } from '@/hooks/useCheckLogin';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export const WorkSpaceButton = () => {
const { isLogin } = useCheckLogin();
if (isLogin) {
return (
<Link
className={cn(
buttonVariants({
variant: 'default'
})
)}
href={'/home'}>
工作台
</Link>
);
}
return (
<Link
className={cn(
buttonVariants({
variant: 'ghost'
})
)}
href={'/login'}>
登录
</Link>
);
};
点击按钮后跳转到登录接口,点击一下发送验证码,然后拿到验证码之后我们就去登陆
我们输入正确的验证码之后会跳转到首页去
下面我们来创建工作台的页面
这个页面就是纯code,我就快速带过了,直接上代码了,自己看吧。
sideBar
import { FC } from 'react';
import { Logo } from '@/components/Logo';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import UserButton from '@/components/UserButton';
type SideBarProps = {
className?: string;
};
export const SideBar: FC<SideBarProps> = ({ className = '' }) => {
return (
<div className={cn('w-60 bg-black h-full absolute md:fixed text-white', className)}>
<div className={'py-5 px-2.5 flex flex-col w-full'}>
<div className={'w-full flex justify-center items-center'}>
<Logo></Logo>
</div>
<div className={'flex flex-col gap-4 mt-4 px-4 flex-1'}>
<Link
href={'/home?x'}
className={cn(
buttonVariants({
variant: 'ghost'
}),
'justify-stretch'
)}>
home
</Link>
</div>
<UserButton />
</div>
</div>
);
};
homePageLayout
'use client';
import { createContext, FC, PropsWithChildren, useState } from 'react';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Menu, X } from 'lucide-react';
import * as React from 'react';
import { useCheckLogin } from '@/hooks/useCheckLogin';
import { useRouter } from 'next/navigation';
import { SideBar } from '@/components/layout/sideBar';
import { Logo } from '@/components/Logo';
import UserButton from '@/components/UserButton';
export const homeLayoutContext = createContext<{
triggerSideBar: (open: boolean) => void;
}>({} as any);
type HomePageLayoutProps = {};
export const HomePageLayout: FC<PropsWithChildren<HomePageLayoutProps>> = ({ children }) => {
const router = useRouter();
const { isLogin } = useCheckLogin(isLogin => {
if (!isLogin) {
router.replace(`/login?redirect=${encodeURIComponent(window.location.href)}`);
}
});
const [sideBarOpen, setSideBarOpen] = useState(false);
if (!isLogin) return null;
return (
<homeLayoutContext.Provider
value={{
triggerSideBar: setSideBarOpen
}}>
<div className={'flex flex-col min-h-full flex-1'}>
<SideBar className={'hidden md:flex'} />
<Sheet
open={sideBarOpen}
modal
onOpenChange={flag => {
setSideBarOpen(flag);
}}>
<SheetContent hiddenCloseBtn side={'left'} className={'p-0 max-w-60 sm:max-w-60 outline-none'}>
<div
onClick={() => {
setSideBarOpen(prev => !prev);
}}
className={
'absolute right-4 top-4 rounded-sm opacity-70 disabled:pointer-events-none data-[state=open]:bg-secondary z-10 text-white bg-gray-400 cursor-pointer'
}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</div>
<SideBar />
</SheetContent>
</Sheet>
{/*mobile header*/}
<div className={'flex md:hidden justify-between px-4 h-14 items-center'}>
<div
className={'cursor-pointer'}
onClick={() => {
setSideBarOpen(true);
}}>
<Menu />
</div>
<Logo></Logo>
<div>
<UserButton className={'flex'} />
</div>
</div>
<div className={'ml-0 md:ml-60'}>
<div>{children}</div>
</div>
</div>
</homeLayoutContext.Provider>
);
};
UserButton
'use client';
import React, { FC, useState } from 'react';
import { useAppStore } from '@/components/providers/appStoreProvider';
import { cn } from '@/lib/utils';
import {Loader, User} from 'lucide-react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '@/components/ui/dropdown-menu';
import { useInitContext } from '@/components/providers/initProvider';
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {z} from 'zod'
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {nickNameSchema} from "@/schema";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
import {trpc} from "@/app/_trpc";
import {toast} from "sonner";
type UserButtonProps = {
className?: string;
};
const UserButton: FC<UserButtonProps> = ({ className = '' }) => {
const [profile] = useAppStore(state => [state.userProfile]);
const [openSetting, setOpenSetting] = useState(false);
const { logOut } = useInitContext();
const form = useForm<z.infer<typeof nickNameSchema>>({
resolver:zodResolver(nickNameSchema),
defaultValues:{
nickName: profile.nickName
}
})
const changeUser = trpc.changeUser.useMutation({
onSuccess: ctx => {
if (ctx.success) {
setOpenSetting(false)
toast('修改成功')
}
},
onError(error){
toast(error.message)
}
})
const onSubmit = (values:z.infer<typeof nickNameSchema>)=>{
if(changeUser.isPending)return
changeUser.mutate(values)
}
return (
<div className={cn('w-full justify-center items-center md:flex cursor-pointer', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className={'w-full'}>
<div className={'hidden md:block text-ellipsis line-clamp-1 px-4'}>{profile.email}</div>
<div className={'block md:hidden'}>
<User />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side={'right'} className={'min-w-40'}>
<DropdownMenuItem
onClick={() => {
setOpenSetting(prev => !prev);
}}>
<span>设置</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={logOut}>
<span>退出登陆</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={openSetting} onOpenChange={setOpenSetting}>
<DialogContent className="sm:max-w-md">
<div className={'text-xl font-medium'}>修改账号信息</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-10 grid grid-cols-1 gap-y-8">
<FormField
control={form.control}
render={({ field }) => {
return (
<FormItem className={'grid gap-2 relative'}>
<FormLabel>nickname</FormLabel>
<FormControl>
<Input placeholder={'请输入昵称'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
name={'nickName'}
/>
<Button type="submit" variant="default" className="w-full flex gap-2">
确定修改 <span aria-hidden="true">→</span>
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
);
};
export default UserButton;
WorkSpace
'use client';
import { useEffect } from 'react';
import { HomePageLayout } from '@/components/layout/homePage';
import 'react-loading-skeleton/dist/skeleton.css';
import Skeleton from 'react-loading-skeleton';
const HomePage = () => {
useEffect(() => {
document.title = 'home';
}, []);
return (
<HomePageLayout>
<div>
<div className={'h-14 flex justify-between p-4 border-b border-gray-300 items-center'}>
<span className={'text-xl font-medium'}>WorkSpace</span>
</div>
{/*这里根据业务获取数据了*/}
<div className={'p-4 grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-2 md:gap-4'}>
{[1, 2, 3, 4, 5].map(item => {
return (
<div key={item + ''} className={'w-full '}>
<Skeleton count={5} />
</div>
);
})}
</div>
</div>
</HomePageLayout>
);
};
export default HomePage;
增加修改用户信息接口
import {privateProcedure, publicProcedure, router} from './trpc';
import {emailCodeSchema, loginFormSchema, nickNameSchema} from "@/schema";
import {RedisDb} from "@/db/redis";
import {IResponse} from "@/lib/res";
import {generateRandomStr,encrypt} from "@/lib/utils";
import nodemailer from 'nodemailer';
import {Db} from "@/db";
import {TRPCError} from "@trpc/server";
import {CustomBaseException} from "@/lib/error";
const transporter = nodemailer.createTransport({
service: 'qq',
host: 'smtp.qq.com',
auth: {
user: '424139777@qq.com',
pass: '****'
}
});
export const appRouter = router({
// 登录
login:publicProcedure.input(loginFormSchema).mutation(async ({ctx,input})=>{
const { email, code } = input;
const db = await Db.install;
const redis = await RedisDb.install;
const key = `${email}__${code}`;
const hasCode = await redis.install.get(key);
if (!hasCode) {
throw new TRPCError({
code: 'BAD_REQUEST',
cause: new CustomBaseException(
{
message: '验证码错误,请重新输入',
meta: {
code: 40001,
data: 'tete'
}
},
400
)
});
}
// 删除
void redis.install.del(key);
// 判断是否存在
let user = await db.getRepository('SassStartUserEntity').findOne({
where: {
email: email
},
withDeleted: true
});
if (user === null) {
user = await db.getRepository('SassStartUserEntity').save({
email: email,
password: 'test',
nickName:email
});
}
user.password = null as unknown as any;
return IResponse.ok({
accessToken: await encrypt({
user
})
});
}),
// 发送邮箱验证码
getCode: publicProcedure.input(emailCodeSchema).mutation(async ({ ctx, input }) => {
const { email } = input;
const redis = await RedisDb.install;
const code = generateRandomStr(10, 6);
await redis.install.setEx(`${email}__${code}`, code, 60 * 5);
const mail = await transporter.sendMail({
from: '424139777@qq.com',
to: email,
subject: `Website activity from ${email}`,
html: `
<p>你的验证码是: ${code} </p>
`
});
transporter.close();
return IResponse.ok({});
}),
getUser: privateProcedure.query(async ({ ctx }) => {
return {
user: ctx.user
};
}),
changeUser:privateProcedure.input(nickNameSchema).mutation(async ({ctx,input})=>{
try{
const {nickName} = input;
const userId = ctx.user.id;
const db = await Db.install;
await db.getRepository('SassStartUserEntity').update(userId,{
nickName:nickName
})
return IResponse.ok({})
}catch (e){
throw new TRPCError({
code:"INTERNAL_SERVER_ERROR",
cause: new CustomBaseException(
{
message: '未知错误,请稍后重试',
meta: {
code: 50001,
}
},
500
)
})
}
})
});
export type AppRouter = typeof appRouter;
如何配置smtp的发送邮件
打开各自邮箱的smtp转发设置,qq邮箱的如下设置
项目仓库地址:sass-startup