From adbe09d074edb7f479e3938ae1c94ef8f7bd5ed2 Mon Sep 17 00:00:00 2001 From: tungdtsothink Date: Tue, 8 Jul 2025 14:04:53 +0700 Subject: [PATCH] Add authentication --- middleware.ts | 61 ++ package-lock.json | 159 ++++- package.json | 4 + src/app/api/auth/route.ts | 31 +- src/app/api/auth/verify/route.ts | 43 ++ src/app/api/user/[id]/permissions/route.ts | 20 +- src/app/auth/login/page.tsx | 23 +- src/app/layout.tsx | 7 +- src/app/modules/customer/[id]/page.tsx | 774 ++++++++++++--------- src/app/modules/layout.tsx | 3 + src/app/page.tsx | 125 +--- src/components/common/ProtectedRoute.tsx | 34 + src/components/common/header.tsx | 38 + src/components/users/user-columns.tsx | 10 +- src/contexts/AuthContext.tsx | 101 +++ src/database/db.json | 19 + 16 files changed, 992 insertions(+), 460 deletions(-) create mode 100644 middleware.ts create mode 100644 src/app/api/auth/verify/route.ts create mode 100644 src/components/common/ProtectedRoute.tsx create mode 100644 src/contexts/AuthContext.tsx diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..2f895f9 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +// Define protected routes +const protectedRoutes = ['/modules']; + + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Check if the current path is a protected route + const isProtectedRoute = protectedRoutes.some(route => + pathname.startsWith(route) + ); + + + + // Get token from cookies + const token = request.cookies.get('auth-token')?.value; + + // If accessing a protected route without a token, redirect to login + if (isProtectedRoute && !token) { + return NextResponse.redirect(new URL('/auth/login', request.url)); + } + + // Verify token if it exists + if (token) { + try { + jwt.verify(token, JWT_SECRET); + + // If user is authenticated and trying to access login page, redirect to modules + if (pathname === '/auth/login') { + return NextResponse.redirect(new URL('/modules/user', request.url)); + } + } catch { + // Invalid token - remove it and redirect to login if accessing protected route + const response = NextResponse.redirect(new URL('/auth/login', request.url)); + response.cookies.delete('auth-token'); + return response; + } + } + + // Allow the request to continue + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api|_next/static|_next/image|favicon.ico).*)', + ], +}; diff --git a/package-lock.json b/package-lock.json index e6c2123..0d9bece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,10 +22,14 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", + "@types/js-cookie": "^3.0.6", + "@types/jsonwebtoken": "^9.0.10", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lowdb": "^7.0.1", "lucide-react": "^0.525.0", "next": "15.3.5", @@ -2297,6 +2301,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2311,6 +2321,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", @@ -2328,11 +2348,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.4.tgz", "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3260,6 +3285,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3680,6 +3711,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5269,6 +5309,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5323,6 +5372,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5339,6 +5410,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5638,6 +5730,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5645,6 +5773,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5812,7 +5946,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6570,6 +6703,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6615,7 +6768,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7342,7 +7494,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 4078fe0..37a2071 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,14 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", + "@types/js-cookie": "^3.0.6", + "@types/jsonwebtoken": "^9.0.10", "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lowdb": "^7.0.1", "lucide-react": "^0.525.0", "next": "15.3.5", diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index c142c4e..18d374b 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db, initDB, resetDB } from '@/database/database'; +import { db, initDB } from '@/database/database'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; export async function POST(req: NextRequest) { await initDB(); @@ -8,8 +11,30 @@ export async function POST(req: NextRequest) { (u) => u.username === username && u.password === password && !u.isDeleted ); if (user) { - // In a real app, return a JWT or session token - return NextResponse.json({ success: true, user: { id: user.id, username: user.username, email: user.email, firstName: user.firstName, lastName: user.lastName } }); + // Create JWT token + const token = jwt.sign( + { + id: user.id.toString(), + username: user.username, + email: user.email + }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + const userData = { + id: user.id.toString(), + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName + }; + + return NextResponse.json({ + success: true, + token, + user: userData + }); } else { return NextResponse.json({ success: false, message: 'Invalid credentials' }, { status: 401 }); } diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..7736d33 --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, initDB } from '@/database/database'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export async function POST(req: NextRequest) { + try { + await initDB(); + const { token } = await req.json(); + + if (!token) { + return NextResponse.json({ success: false, message: 'No token provided' }, { status: 401 }); + } + + // Verify the token + const decoded = jwt.verify(token, JWT_SECRET) as { id: string; username: string; email: string }; + + // Find the user in the database + const user = db.data?.users.find( + (u) => u.id.toString() === decoded.id && !u.isDeleted + ); + + if (!user) { + return NextResponse.json({ success: false, message: 'User not found' }, { status: 401 }); + } + + const userData = { + id: user.id.toString(), + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName + }; + + return NextResponse.json({ + success: true, + user: userData + }); + } catch { + return NextResponse.json({ success: false, message: 'Invalid token' }, { status: 401 }); + } +} diff --git a/src/app/api/user/[id]/permissions/route.ts b/src/app/api/user/[id]/permissions/route.ts index de75e28..6a0bd63 100644 --- a/src/app/api/user/[id]/permissions/route.ts +++ b/src/app/api/user/[id]/permissions/route.ts @@ -13,10 +13,11 @@ function writeDB() { // GET /api/user/[id]/permissions - Get all permissions for a user export async function GET( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const userId = parseInt(params.id); + const { id } = await params; + const userId = parseInt(id); if (isNaN(userId)) { return NextResponse.json({ error: "Invalid user ID" }, { status: 400 }); @@ -51,10 +52,11 @@ export async function GET( // POST /api/user/[id]/permissions - Add permissions to a user export async function POST( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const userId = parseInt(params.id); + const { id } = await params; + const userId = parseInt(id); if (isNaN(userId)) { return NextResponse.json({ error: "Invalid user ID" }, { status: 400 }); @@ -126,10 +128,11 @@ export async function POST( // PUT /api/user/[id]/permissions - Update permissions for a user export async function PUT( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const userId = parseInt(params.id); + const { id } = await params; + const userId = parseInt(id); if (isNaN(userId)) { return NextResponse.json({ error: "Invalid user ID" }, { status: 400 }); @@ -201,10 +204,11 @@ export async function PUT( // DELETE /api/user/[id]/permissions - Remove all permissions from a user export async function DELETE( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const userId = parseInt(params.id); + const { id } = await params; + const userId = parseInt(id); if (isNaN(userId)) { return NextResponse.json({ error: "Invalid user ID" }, { status: 400 }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 5fe8ece..9699558 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -4,8 +4,10 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import axios from "axios"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { loginSchema } from "@/schemas/auth.schema"; +import { useAuth } from "@/contexts/AuthContext"; import { Form } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,6 +21,15 @@ export default function LoginPage() { }); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const { login, isAuthenticated } = useAuth(); + const router = useRouter(); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + router.push('/modules/user'); + } + }, [isAuthenticated, router]); const onSubmit = async (data: LoginForm) => { setError(""); @@ -26,13 +37,15 @@ export default function LoginPage() { try { const res = await axios.post("/api/auth", data); if (res.data.success) { - // You can redirect or set session here - window.location.href = "/modules/user"; // Redirect to user page on successful login + // Use the authentication context to login + login(res.data.token, res.data.user); + router.push("/modules/user"); } else { setError(res.data.message || "Login failed"); } - } catch (e: any) { - setError(e.response?.data?.message || "Login failed"); + } catch (e: unknown) { + const error = e as { response?: { data?: { message?: string } } }; + setError(error.response?.data?.message || "Login failed"); } finally { setLoading(false); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2c7a9b..a289e49 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "@/components/ui/sonner"; +import { AuthProvider } from "@/contexts/AuthContext"; import "./globals.css"; const geistSans = Geist({ @@ -28,8 +29,10 @@ export default function RootLayout({ - {children} - + + {children} + + ); diff --git a/src/app/modules/customer/[id]/page.tsx b/src/app/modules/customer/[id]/page.tsx index cc577cb..b2d9814 100644 --- a/src/app/modules/customer/[id]/page.tsx +++ b/src/app/modules/customer/[id]/page.tsx @@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -246,6 +246,65 @@ export default function CustomerEditPage() { toast.success("Dependant removed successfully!"); }; + const handleNextTab = async () => { + if (activeTab === "info") { + // Validate customer info fields before proceeding + const isValid = await form.trigger([ + "customerInfo.firstNameEn", + "customerInfo.lastNameEn", + "customerInfo.originAdd1", + "customerInfo.localAdd1", + ]); + if (isValid) { + setActiveTab("contact"); + } else { + toast.error("Please fill in all required fields in Customer Info tab."); + } + } else if (activeTab === "contact") { + // Validate customer contact fields before proceeding + const isValid = await form.trigger(["customerContact.email", "customerContact.mobile"]); + if (isValid) { + setActiveTab("dependants"); + } else { + toast.error("Please fill in all required fields in Customer Contact tab."); + } + } + }; + + const handlePreviousTab = () => { + if (activeTab === "contact") { + setActiveTab("info"); + } else if (activeTab === "dependants") { + setActiveTab("contact"); + } + }; + + const isTabValid = (tabName: string) => { + const values = form.getValues(); + const errors = form.formState.errors; + + if (tabName === "info") { + return ( + values.customerInfo?.firstNameEn?.trim() !== "" && + values.customerInfo?.lastNameEn?.trim() !== "" && + values.customerInfo?.originAdd1?.trim() !== "" && + values.customerInfo?.localAdd1?.trim() !== "" && + !errors.customerInfo?.firstNameEn && + !errors.customerInfo?.lastNameEn && + !errors.customerInfo?.originAdd1 && + !errors.customerInfo?.localAdd1 + ); + } else if (tabName === "contact") { + return ( + values.customerContact?.email?.trim() !== "" && + values.customerContact?.mobile?.trim() !== "" && + !errors.customerContact?.email && + !errors.customerContact?.mobile + ); + } + return true; + }; + if (isLoading) { return (
@@ -263,10 +322,9 @@ export default function CustomerEditPage() { breadcrumbs={[ { title: "Home", href: "/" }, { title: "Customer Management", href: "/modules/customer" }, - { title: "Edit Customer", isCurrentPage: true } + { title: "Edit Customer", isCurrentPage: true }, ]} - /> -
+ />
- -
+
+ + + Edit the customer information across the three tabs below. + + + +
+ + + + + Customer Info + + + Customer Contact + + + Customer Dependants + + +
+ ( + + First Name (English) + + + + + + )} + /> + ( + + Last Name (English) + + + + + + )} + /> +
+
+ ( + + Origin Address + + + + + + )} + /> + ( + + Local Address + + + + + + )} + /> +
+
+ +
+
+
+ ( + + Email + + + + + + )} + /> + ( + + Mobile Number + + + + + + )} + /> +
+
+ + +
+
+
+

Customer Dependants

+ + + + + + + + {editingDependant ? "Edit Dependant" : "Add New Dependant"} + + + + + + + Dependant Info + Dependant Contact + - - - - - Customer Info - Contact Details - Dependants - - - - - - Customer Information - Edit the customer's basic information - - -
- ( - - First Name (English) - - - - - - )} - /> - ( - - Last Name (English) - - - - - - )} - /> -
-
- ( - - Origin Address - - - - - - )} - /> - ( - - Local Address - - - - - - )} - /> -
-
-
-
- - - - - Contact Details - Edit the customer's contact information - - - ( - - Email - - - - - - )} - /> - ( - - Mobile - - - - - - )} - /> - - - - - - - - Customer Dependants - Manage customer dependants - - -
-

- Dependants ({watchedDependants.length}) -

- - - - - - - - {editingDependant ? "Edit Dependant" : "Add New Dependant"} - - - - - - - Personal Info - Contact Details - - - -
- ( - - First Name (English) - - - - - - )} - /> - ( - - Last Name (English) - - - - - - )} - /> -
-
- ( - - Origin Address - - - - - - )} - /> - ( - - Local Address - - - - - - )} - /> -
-
- - - ( - - Email - - - - - - )} - /> - ( - - Mobile - - - - - - )} - /> - -
- -
- + +
+ ( + + First Name (English) + + + + + + )} + /> + ( + + Last Name (English) + + + + + + )} + /> +
+
+ ( + + Origin Address + + + + + + )} + /> + ( + + Local Address + + + + + + )} + /> +
+
- - - -
-
+
- {watchedDependants.length > 0 ? ( - - - - Name - Email - Mobile - Origin Address - Local Address - Actions + +
+ ( + + Email + + + + + + )} + /> + ( + + Mobile Number + + + + + + )} + /> +
+
+ +
+
+ +
+ + +
+ + + + + + + {watchedDependants.length > 0 ? ( +
+
+ + + First Name + Last Name + Email + Mobile + Origin Address + Local Address + Actions + + + + {watchedDependants.map((dependant) => ( + + {dependant.dependantInfo.firstNameEn} + {dependant.dependantInfo.lastNameEn} + {dependant.dependantContact.email} + {dependant.dependantContact.mobile} + {dependant.dependantInfo.originAdd1} + {dependant.dependantInfo.localAdd1} + +
+ + +
+
- - - {watchedDependants.map((dependant) => ( - - - {dependant.dependantInfo.firstNameEn} {dependant.dependantInfo.lastNameEn} - - {dependant.dependantContact.email} - {dependant.dependantContact.mobile} - {dependant.dependantInfo.originAdd1} - {dependant.dependantInfo.localAdd1} - -
- - -
-
-
- ))} -
-
+ ))} + + +
+ ) : ( +
+ No dependants added yet. Click "Add Dependant" to add one. +
+ )} + +
+ + +
+
+ + +
+
+
- - ); -} + ); + } diff --git a/src/app/modules/layout.tsx b/src/app/modules/layout.tsx index 13636b9..5ccaacd 100644 --- a/src/app/modules/layout.tsx +++ b/src/app/modules/layout.tsx @@ -1,4 +1,5 @@ import { AppSidebar } from "@/components/sidebar/app-sidebar"; +import { ProtectedRoute } from "@/components/common/ProtectedRoute"; import { SidebarInset, SidebarProvider, @@ -10,6 +11,7 @@ export default function ModulesLayout({ children: React.ReactNode; }>) { return ( +
@@ -18,5 +20,6 @@ export default function ModulesLayout({
+
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index e68abe6..87c8695 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,30 @@ -import Image from "next/image"; +"use client"; + +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); -
- - Vercel logomark - Deploy now - - - Read our docs - -
-
- -
- ); + useEffect(() => { + if (!isLoading) { + if (isAuthenticated) { + router.push('/modules/user'); + } else { + router.push('/auth/login'); + } + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return null; } diff --git a/src/components/common/ProtectedRoute.tsx b/src/components/common/ProtectedRoute.tsx new file mode 100644 index 0000000..9dcea8b --- /dev/null +++ b/src/components/common/ProtectedRoute.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useAuth } from '@/contexts/AuthContext'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/auth/login'); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return <>{children}; +}; diff --git a/src/components/common/header.tsx b/src/components/common/header.tsx index aa3d7b5..98d914e 100644 --- a/src/components/common/header.tsx +++ b/src/components/common/header.tsx @@ -1,6 +1,12 @@ +"use client"; + import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { Separator } from "@radix-ui/react-select"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/contexts/AuthContext"; +import { LogOut, User } from "lucide-react"; interface BreadcrumbItemData { title: string; @@ -23,6 +29,8 @@ export function Header({ showParent = true, breadcrumbs }: HeaderProps) { + const { user, logout } = useAuth(); + // Use breadcrumbs prop if provided, otherwise fall back to legacy props const breadcrumbItems = breadcrumbs || [ ...(showParent ? [{ title: parentTitle, href: parentHref }] : []), @@ -55,6 +63,36 @@ export function Header({ ))} + + {/* User Dropdown */} +
+ + + + + + +
+

+ {user?.firstName} {user?.lastName} +

+

+ {user?.email} +

+
+
+ + + + Log out + +
+
+
); } diff --git a/src/components/users/user-columns.tsx b/src/components/users/user-columns.tsx index 11ff23a..3797550 100644 --- a/src/components/users/user-columns.tsx +++ b/src/components/users/user-columns.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { Key, ArrowUpDown } from "lucide-react"; +import { Key, ArrowUpDown, Trash2 } from "lucide-react"; import { Column } from "@tanstack/react-table"; export interface User { @@ -102,7 +102,13 @@ export function createUserColumns({ onEdit, onDelete, onPermissions }: { )} {onDelete && ( - )} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..af5f9d4 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import Cookies from 'js-cookie'; +import { useRouter } from 'next/navigation'; + +interface User { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; +} + +interface AuthContextType { + user: User | null; + login: (token: string, userData: User) => void; + logout: () => void; + isAuthenticated: boolean; + isLoading: boolean; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + const login = (token: string, userData: User) => { + Cookies.set('auth-token', token, { expires: 7 }); // 7 days + setUser(userData); + }; + + const logout = () => { + Cookies.remove('auth-token'); + setUser(null); + router.push('/auth/login'); + }; + + const verifyToken = async (token: string) => { + try { + const response = await fetch('/api/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + setUser(data.user); + return true; + } + } + return false; + } catch (error) { + console.error('Token verification failed:', error); + return false; + } + }; + + useEffect(() => { + const initAuth = async () => { + const token = Cookies.get('auth-token'); + if (token) { + const isValid = await verifyToken(token); + if (!isValid) { + Cookies.remove('auth-token'); + } + } + setIsLoading(false); + }; + + initAuth(); + }, []); + + const value = { + user, + login, + logout, + isAuthenticated: !!user, + isLoading, + }; + + return {children}; +}; diff --git a/src/database/db.json b/src/database/db.json index 794442a..aa727d0 100644 --- a/src/database/db.json +++ b/src/database/db.json @@ -44,6 +44,15 @@ "mobile": "+1-416-555-0199", "originAdd1": "100 Queen Street West, Toronto, ON M5H 2N2, Canada", "localAdd1": "987 Ginza District, Tokyo, Japan 104-0061" + }, + { + "id": 6, + "firstNameEn": "Đỗ", + "lastNameEn": "Thanh Tùng", + "email": "dothanhtung196@gmail.com", + "mobile": "0987417491", + "originAdd1": "123", + "localAdd1": "sdfgsdfg" } ], "users": [ @@ -621,6 +630,16 @@ "mobile": "+1-416-555-0191", "originAdd1": "100 Queen Street West, Toronto, ON M5H 2N2, Canada", "localAdd1": "987 Ginza District, Tokyo, Japan 104-0061" + }, + { + "id": 10, + "custId": 6, + "firstNameEn": "Đỗ", + "lastNameEn": "Thanh Tùng", + "email": "dothanhtung196@gmail.com", + "mobile": "0987417491", + "originAdd1": "123", + "localAdd1": "sdfgsdfg" } ] } \ No newline at end of file