完成了管理后台的搭建,实现了登录鉴权
This commit is contained in:
parent
bb3427010c
commit
0ec61b5ad4
|
@ -0,0 +1,49 @@
|
|||
"use server";
|
||||
import { createSession, deleteSession } from '@/app/_lib/session'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { FormState, LoginFormSchema } from '../definitions'
|
||||
import { getUser } from '../data/user'
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export async function login(state: FormState, formData: FormData) {
|
||||
|
||||
const _account = formData.get('account')?.toString()
|
||||
const validatedFields = LoginFormSchema.safeParse({
|
||||
account: formData.get('account'),
|
||||
password: formData.get('password')
|
||||
})
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
const { account, password } = validatedFields.data
|
||||
if (!_account) {
|
||||
return {
|
||||
message: '用户名或者密码错误',
|
||||
}
|
||||
}
|
||||
const user = await getUser(account)
|
||||
if (!user) {
|
||||
return {
|
||||
message: '用户名或者密码错误',
|
||||
}
|
||||
|
||||
}
|
||||
const passwordsMatch = await bcrypt.compare(password, user.password);
|
||||
if (!passwordsMatch) {
|
||||
|
||||
return {
|
||||
message: '用户名或者密码错误',
|
||||
}
|
||||
}
|
||||
|
||||
await createSession(user._id)
|
||||
// 5. Redirect user
|
||||
redirect('/admin')
|
||||
|
||||
}
|
||||
export async function logout() {
|
||||
deleteSession()
|
||||
redirect('/admin/login')
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import 'server-only'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { decrypt } from '@/app/_lib/session'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { cache } from 'react'
|
||||
|
||||
export const verifySession = cache(async () => {
|
||||
const cookie = (await cookies()).get('session')?.value
|
||||
const session = await decrypt(cookie)
|
||||
|
||||
if (!session?.userId) {
|
||||
redirect('/admin/login')
|
||||
}
|
||||
|
||||
return { isAuth: true, userId: session.userId }
|
||||
})
|
|
@ -0,0 +1,15 @@
|
|||
import { getCollection } from "../mongodb";
|
||||
|
||||
export type User = {
|
||||
_id: string;
|
||||
name: string;
|
||||
role: number;
|
||||
account: string;
|
||||
password: string;
|
||||
}
|
||||
export async function getUser(account: string) {
|
||||
const collection = await getCollection('user')
|
||||
const user = await collection.findOne<User>({ account })
|
||||
|
||||
return user
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const SignupFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: '至少应该超过两个字符' })
|
||||
.trim(),
|
||||
account: z.string(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: '密码过于简单' })
|
||||
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
|
||||
.regex(/[0-9]/, { message: 'Contain at least one number.' })
|
||||
.regex(/[^a-zA-Z0-9]/, {
|
||||
message: 'Contain at least one special character.',
|
||||
})
|
||||
.trim(),
|
||||
})
|
||||
export const LoginFormSchema = z.object({
|
||||
account: z.string().min(1, "账号必填").trim(),
|
||||
password: z.string().trim(),
|
||||
})
|
||||
export type FormState =
|
||||
{
|
||||
errors?: {
|
||||
account?: string[]
|
||||
password?: string[]
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
| undefined
|
||||
export type SessionPayload = {
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// lib/mongodb.ts
|
||||
import { MongoClient, Db } from 'mongodb';
|
||||
|
||||
|
||||
const uri = process.env.MONGODB_URI;
|
||||
let client: MongoClient;
|
||||
let clientPromise: Promise<MongoClient>;
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('Please add your Mongo URI to.env.local');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// In development mode, use a global variable so that the value
|
||||
// is preserved across module reloads caused by HMR (Hot Module Replacement).
|
||||
if (!global._mongoClientPromise) {
|
||||
client = new MongoClient(uri);
|
||||
global._mongoClientPromise = client.connect();
|
||||
}
|
||||
clientPromise = global._mongoClientPromise;
|
||||
} else {
|
||||
// In production mode, it's best to not use a global variable.
|
||||
client = new MongoClient(uri);
|
||||
clientPromise = client.connect();
|
||||
}
|
||||
|
||||
export const getDb = async (): Promise<Db> => {
|
||||
const client = await clientPromise;
|
||||
return client.db('ai-bot');
|
||||
};
|
||||
export const getCollection = async (collection: string) => {
|
||||
const client = await clientPromise;
|
||||
return client.db('ai-bot').collection(collection);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
import { message } from "antd"
|
||||
|
||||
/**
|
||||
* 发送请求
|
||||
* @param method
|
||||
* @param url
|
||||
* @param requestConfig
|
||||
*/
|
||||
|
||||
export async function mRequest<T>(
|
||||
method: 'GET' | 'POST' | 'DELETE' | 'PUT',
|
||||
url: string,
|
||||
data?: any,
|
||||
responseType: 'json' | 'text' | 'blob' = 'json'
|
||||
): Promise<T> {
|
||||
let options: RequestInit = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
if (responseType === 'json') {
|
||||
return response.json() as Promise<T>;
|
||||
} else if (responseType === 'text') {
|
||||
return response.text() as Promise<T>;
|
||||
} else if (responseType === 'blob') {
|
||||
return response.blob() as Promise<T>;
|
||||
} else {
|
||||
throw new Error('Invalid response type specified');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import 'server-only'
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { SessionPayload } from '@/app/_lib/definitions'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
const secretKey = process.env.SESSION_SECRET
|
||||
const encodedKey = new TextEncoder().encode(secretKey)
|
||||
|
||||
export async function encrypt(payload: SessionPayload) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(encodedKey)
|
||||
}
|
||||
|
||||
export async function decrypt(session: string | undefined = '') {
|
||||
try {
|
||||
const { payload } = await jwtVerify(session, encodedKey, {
|
||||
algorithms: ['HS256'],
|
||||
})
|
||||
return payload
|
||||
} catch (error) {
|
||||
console.log('Failed to verify session', error)
|
||||
}
|
||||
}
|
||||
export async function createSession(userId: string) {
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
const session = await encrypt({ userId, expiresAt })
|
||||
const cookieStore = await cookies()
|
||||
|
||||
cookieStore.set('session', session, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: expiresAt,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
export async function updateSession() {
|
||||
const session = (await cookies()).get('session')?.value
|
||||
const payload = await decrypt(session)
|
||||
|
||||
if (!session || !payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('session', session, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
expires: expires,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
export async function deleteSession() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete('session')
|
||||
}
|
|
@ -13,7 +13,7 @@ export type LinkItem = {
|
|||
export type LinkTypeItem = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
id: number;
|
||||
id: string | number;
|
||||
href?: string;
|
||||
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
import { logout } from "../_lib/actions/auth";
|
||||
|
||||
export default function LoginState() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span>
|
||||
<form action={async () => {
|
||||
"use server";
|
||||
await logout()
|
||||
}}>
|
||||
<button className="text-sm border-b border-sky-300 hover:border-b-2 font-normal">退出登录</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
|
@ -30,7 +30,7 @@ export default function SiderNav({ linkList }: { linkList: LinkTypeItem[] }) {
|
|||
})
|
||||
}
|
||||
</nav>
|
||||
|
||||
|
||||
</div >
|
||||
)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import Logo from './Logo'
|
||||
import { login } from '../_lib/actions/auth'
|
||||
|
||||
export default function SignupForm() {
|
||||
const [state, action, pending] = useActionState(login, undefined)
|
||||
|
||||
return (
|
||||
<div className='p-3 shadow-md rounded-lg '>
|
||||
<div className='w-full flex justify-center items-center ' onClick={e => e.stopPropagation()}>
|
||||
<div className='w-[200px]'>
|
||||
<Logo></Logo>
|
||||
|
||||
</div>
|
||||
<span className='text-[30px] font-bold text-[#143B52]'>后台管理平台</span>
|
||||
</div>
|
||||
<form action={action} className='p-2 flex flex-col gap-3 items-center '>
|
||||
<div className='flex '>
|
||||
<label htmlFor="account" className='w-[50px]'>账号</label>
|
||||
<input id="account" name="account" className='rounded py-1 pl-2 outline-none border-slate-200 border-[1px] ' />
|
||||
</div>
|
||||
<div className='w-full pl-10 text-red-500 text-[13px]'>
|
||||
|
||||
{state?.errors?.account && <p>{state.errors.account}</p>}
|
||||
</div>
|
||||
|
||||
<div className='flex'>
|
||||
<label htmlFor="password" className='w-[50px]'>密码</label>
|
||||
<input id="password" name="password" type="password" className='rounded py-1 pl-2 outline-none border-slate-200 border-[1px] ' />
|
||||
</div>
|
||||
<div className='w-full pl-10 text-red-500'>
|
||||
|
||||
|
||||
{state?.errors?.password && <p>{state.errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<button disabled={pending} type="submit" className='rounded-md mt-2 bg-blue-400 text-white w-[150px] hover:opacity-90 shadow-sm py-2 text-[14px]'>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='w-full pl-5 text-red-500 flex justify-center'>
|
||||
{state?.message && <p>{state.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { Space } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
|
||||
export default [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'age',
|
||||
key: 'age',
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
render: (_, record) => (
|
||||
<Space size="middle" >
|
||||
< a > Delete </a>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
] as ColumnsType<unknown>;
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
import { Button, Card, Drawer, Space, Table } from "antd";
|
||||
import { useState } from "react";
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import tableConfig from "./config"
|
||||
import { useAntdTable } from "ahooks";
|
||||
import { User } from "@/app/_lib/data/user";
|
||||
import { mRequest } from "@/app/_lib/request";
|
||||
interface DataType {
|
||||
key: string;
|
||||
name: string;
|
||||
age: number;
|
||||
address: string;
|
||||
tags: string[];
|
||||
}
|
||||
const data: DataType[] = [
|
||||
{
|
||||
key: '1',
|
||||
name: 'John Brown',
|
||||
age: 32,
|
||||
address: 'New York No. 1 Lake Park',
|
||||
tags: ['nice', 'developer'],
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
name: 'Jim Green',
|
||||
age: 42,
|
||||
address: 'London No. 1 Lake Park',
|
||||
tags: ['loser'],
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
name: 'Joe Black',
|
||||
age: 32,
|
||||
address: 'Sydney No. 1 Lake Park',
|
||||
tags: ['cool', 'teacher'],
|
||||
},
|
||||
];
|
||||
export default function Page() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { tableProps, loading, error } = useAntdTable(({ current, pageSize }) => mRequest<{
|
||||
total: number;
|
||||
list: User[];
|
||||
}>('GET', `/api/links?page=${current}&pageSize=${pageSize}}`),
|
||||
{
|
||||
defaultPageSize: 10,
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
<Card
|
||||
title="链接管理"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => {
|
||||
setOpen(true)
|
||||
}}>添加链接</Button>
|
||||
}>
|
||||
<Table
|
||||
columns={tableConfig}
|
||||
dataSource={data}
|
||||
/>
|
||||
</Card>
|
||||
<Drawer open={open} onClose={() => {
|
||||
setOpen(false)
|
||||
}} />
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import LoginState from "@/app/_ui/LoginState";
|
||||
import SiderNav from "../../_ui/SiderNav";
|
||||
import { faMagnet } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SiderNav linkList={[
|
||||
{
|
||||
label: '链接管理',
|
||||
icon: <FontAwesomeIcon icon={faMagnet} className=" fa-fw"></FontAwesomeIcon>,
|
||||
id: 'add',
|
||||
href: '/admin'
|
||||
}
|
||||
]}></SiderNav>
|
||||
<div>
|
||||
<div className="h-[50px] bg-white/80 shadow flex items-center justify-between px-5 ">
|
||||
<span className="font-bold text-xl">后台管理面板</span>
|
||||
|
||||
<LoginState></LoginState>
|
||||
</div>
|
||||
<main className="p-2">
|
||||
|
||||
{children}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import LoginForm from "@/app/_ui/login";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center w-full items-center ">
|
||||
<div className=" absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<LoginForm></LoginForm>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { verifySession } from "@/app/_lib/dal";
|
||||
import { User } from "@/app/_lib/data/user";
|
||||
import { getCollection, getDb } from "@/app/_lib/mongodb";
|
||||
import { message } from "antd";
|
||||
import bcrypt from 'bcrypt';
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const collection = await getCollection('link');
|
||||
// Check if the user is authenticated
|
||||
const session = await verifySession()
|
||||
if (!session) {
|
||||
// User is not authenticated
|
||||
return new Response(null, { status: 401 })
|
||||
}
|
||||
// 获取分页参数
|
||||
const page = parseInt(req.nextUrl.searchParams.get('page') || '1') || 1;
|
||||
const pageSize = parseInt(req.nextUrl.searchParams.get('page') || '10') || 10;
|
||||
|
||||
// 计算起始索引和结束索引
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
|
||||
// 查询数据
|
||||
const cursor = collection.find<User>({}).skip(startIndex).limit(pageSize);
|
||||
const data = await cursor.toArray();
|
||||
|
||||
// 计算总数量
|
||||
const total = await collection.countDocuments();
|
||||
|
||||
return Response.json({
|
||||
total,
|
||||
list: data,
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return Response.error()
|
||||
}
|
||||
}
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 获取待插入的对象
|
||||
const link = await req.json()
|
||||
const collection = await getCollection('link')
|
||||
await collection.insertOne(link)
|
||||
return Response.json({ message: '成功' })
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return Response.error()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { getCollection, getDb } from "@/app/_lib/mongodb";
|
||||
import bcrypt from 'bcrypt';
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const referer = request.nextUrl.searchParams.get('page')
|
||||
return Response.json({
|
||||
referer
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return Response.json('遇到了一些问题')
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import HeaderNav from "./_ui/HeaderNav";
|
||||
import SiderNav from "./_ui/SiderNav";
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||
import Search from "./_ui/Search";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowRotateBack, faDeafness, faImage, faMagnet, faMessage, faPenClip, faSearch, faThumbsUp, faVideo } from '@fortawesome/free-solid-svg-icons'
|
||||
|
|
|
@ -11,6 +11,11 @@ const compat = new FlatCompat({
|
|||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { decrypt } from '@/app/_lib/session'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
// 1. Specify protected and public routes
|
||||
const protectedRoutes = ['/admin/dashboard']
|
||||
const publicRoutes = ['/admin/login']
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
// 2. Check if the current route is protected or public
|
||||
const path = req.nextUrl.pathname
|
||||
const isProtectedRoute = protectedRoutes.includes(path)
|
||||
const isPublicRoute = publicRoutes.includes(path)
|
||||
|
||||
// 3. Decrypt the session from the cookie
|
||||
const cookie = (await cookies()).get('session')?.value
|
||||
const session = await decrypt(cookie)
|
||||
|
||||
// 4. Redirect to /login if the user is not authenticated
|
||||
|
||||
if (isProtectedRoute && !session?.userId) {
|
||||
return NextResponse.redirect(new URL('/admin/login', req.nextUrl))
|
||||
}
|
||||
|
||||
// 5. Redirect to /dashboard if the user is authenticated
|
||||
if (
|
||||
isPublicRoute &&
|
||||
session?.userId &&
|
||||
!req.nextUrl.pathname.startsWith('/admin/dashboard')
|
||||
) {
|
||||
return NextResponse.redirect(new URL('/admin/dashboard', req.nextUrl))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Routes Middleware should not run on
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
|
||||
}
|
|
@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
async redirects() {
|
||||
return [
|
||||
// Basic redirect
|
||||
{
|
||||
source: '/admin',
|
||||
destination: '/admin/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
10
package.json
10
package.json
|
@ -12,14 +12,22 @@
|
|||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"ahooks": "^3.8.4",
|
||||
"antd": "^5.23.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"icons": "link:@awesome.me/kit-KIT_CODE/icons",
|
||||
"jose": "^5.9.6",
|
||||
"mongodb": "^6.12.0",
|
||||
"next": "15.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
1462
pnpm-lock.yaml
1462
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue