完成了管理后台的搭建,实现了登录鉴权

This commit is contained in:
expdsn 2025-01-21 18:41:27 +08:00
parent bb3427010c
commit 0ec61b5ad4
25 changed files with 2075 additions and 7 deletions

View File

49
app/_lib/actions/auth.ts Normal file
View File

@ -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')
}

17
app/_lib/dal.ts Normal file
View File

@ -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 }
})

15
app/_lib/data/user.ts Normal file
View File

@ -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
}

35
app/_lib/definitions.ts Normal file
View File

@ -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;
}

34
app/_lib/mongodb.ts Normal file
View File

@ -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);
};

41
app/_lib/request.ts Normal file
View File

@ -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');
}
}

62
app/_lib/session.ts Normal file
View File

@ -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')
}

View File

@ -13,7 +13,7 @@ export type LinkItem = {
export type LinkTypeItem = { export type LinkTypeItem = {
label: string; label: string;
icon: ReactNode; icon: ReactNode;
id: number; id: string | number;
href?: string; href?: string;
} }

20
app/_ui/LoginState.tsx Normal file
View File

@ -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>
)
}

View File

@ -30,7 +30,7 @@ export default function SiderNav({ linkList }: { linkList: LinkTypeItem[] }) {
}) })
} }
</nav> </nav>
</div > </div >
) )
} }

51
app/_ui/login.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>;

View File

@ -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)
}} />
</>
)
}

View File

@ -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>
);
}

View File

@ -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>
</>
)
}

51
app/api/link/route.ts Normal file
View File

@ -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()
}
}

18
app/api/test/route.ts Normal file
View File

@ -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('遇到了一些问题')
}
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import '@fortawesome/fontawesome-svg-core/styles.css'
import '@ant-design/v5-patch-for-react-19';
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@ -1,6 +1,5 @@
import HeaderNav from "./_ui/HeaderNav"; import HeaderNav from "./_ui/HeaderNav";
import SiderNav from "./_ui/SiderNav"; import SiderNav from "./_ui/SiderNav";
import '@fortawesome/fontawesome-svg-core/styles.css'
import Search from "./_ui/Search"; import Search from "./_ui/Search";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRotateBack, faDeafness, faImage, faMagnet, faMessage, faPenClip, faSearch, faThumbsUp, faVideo } from '@fortawesome/free-solid-svg-icons' import { faArrowRotateBack, faDeafness, faImage, faMagnet, faMessage, faPenClip, faSearch, faThumbsUp, faVideo } from '@fortawesome/free-solid-svg-icons'

View File

@ -11,6 +11,11 @@ const compat = new FlatCompat({
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "",
},
},
]; ];
export default eslintConfig; export default eslintConfig;

40
middleware.ts Normal file
View File

@ -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$).*)'],
}

View File

@ -2,6 +2,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
async redirects() {
return [
// Basic redirect
{
source: '/admin',
destination: '/admin/dashboard',
permanent: true,
},
]
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -12,14 +12,22 @@
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"ahooks": "^3.8.4",
"antd": "^5.23.2",
"bcrypt": "^5.1.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"icons": "link:@awesome.me/kit-KIT_CODE/icons", "icons": "link:@awesome.me/kit-KIT_CODE/icons",
"jose": "^5.9.6",
"mongodb": "^6.12.0",
"next": "15.1.4", "next": "15.1.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

File diff suppressed because it is too large Load Diff