From 0549f12a978b8afbae2913cb490d98e89fe1be64 Mon Sep 17 00:00:00 2001 From: Yolando Date: Sat, 28 Mar 2026 20:38:47 +0700 Subject: [PATCH] feat: implement secure admin authentication system with JWT sessions, middleware protection, and Prisma schema initialization --- docker-compose.yml | 33 ++ package-lock.json | 601 +++++++++++++++++++++- package.json | 12 +- prisma/schema.prisma | 68 +++ prisma/seed.ts | 34 ++ src/app/[locale]/admin/dashboard/page.tsx | 67 +++ src/app/[locale]/admin/login/page.tsx | 24 + src/core/db/prisma.ts | 13 + src/core/security/session.ts | 54 ++ src/features/auth/actions.ts | 48 ++ src/features/auth/login-form.tsx | 92 ++++ src/features/auth/login-schema.ts | 8 + src/middleware.ts | 52 +- 13 files changed, 1100 insertions(+), 6 deletions(-) create mode 100644 docker-compose.yml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 src/app/[locale]/admin/dashboard/page.tsx create mode 100644 src/app/[locale]/admin/login/page.tsx create mode 100644 src/core/db/prisma.ts create mode 100644 src/core/security/session.ts create mode 100644 src/features/auth/actions.ts create mode 100644 src/features/auth/login-form.tsx create mode 100644 src/features/auth/login-schema.ts diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed13305 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: portfolio_postgres + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: password + POSTGRES_DB: portfolio_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + minio: + image: minio/minio + container_name: portfolio_minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: password123 + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + restart: unless-stopped + +volumes: + postgres_data: + minio_data: diff --git a/package-lock.json b/package-lock.json index 47e1124..a153467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,20 @@ "name": "website-portofolio", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "framer-motion": "^12.38.0", + "jose": "^6.2.2", "lucide-react": "^1.7.0", "next": "^15.5.14", "next-intl": "^4.8.3", "next-themes": "^0.4.6", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zod": "^4.3.6" }, "devDependencies": { + "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -24,7 +29,9 @@ "eslint": "^9", "eslint-config-next": "^15.5.14", "postcss": "^8.5.0", + "prisma": "^5.22.0", "tailwindcss": "^3.4.17", + "tsx": "^4.21.0", "typescript": "^5" } }, @@ -70,6 +77,422 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1255,6 +1678,69 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "dev": true, + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "dev": true + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "dev": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "dev": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "dev": true, + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1483,6 +1969,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", + "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", + "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "bcryptjs": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2391,6 +2887,14 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3006,6 +3510,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4366,6 +4911,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5313,6 +5866,25 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6165,6 +6737,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6507,6 +7098,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 6aa7b84..ca2a22f 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,23 @@ "lint": "eslint" }, "dependencies": { + "bcryptjs": "^3.0.3", "framer-motion": "^12.38.0", + "jose": "^6.2.2", "lucide-react": "^1.7.0", "next": "^15.5.14", "next-intl": "^4.8.3", "next-themes": "^0.4.6", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zod": "^4.3.6" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" }, "devDependencies": { + "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -25,7 +33,9 @@ "eslint": "^9", "eslint-config-next": "^15.5.14", "postcss": "^8.5.0", + "prisma": "^5.22.0", "tailwindcss": "^3.4.17", + "tsx": "^4.21.0", "typescript": "^5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a9f1541 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,68 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) @db.Uuid + email String @unique + passwordHash String @map("password_hash") + name String + + @@map("users") +} + +model Project { + id String @id @default(uuid()) @db.Uuid + title String + slug String @unique + description String @db.Text + imageUrl String? @map("image_url") + repoUrl String? @map("repo_url") + liveUrl String? @map("live_url") + category String + isPublished Boolean @default(false) @map("is_published") + createdAt DateTime @default(now()) @map("created_at") + + skills ProjectSkill[] + + @@map("projects") +} + +model Skill { + id String @id @default(uuid()) @db.Uuid + name String + iconName String? @map("icon_name") + category String + + projects ProjectSkill[] + + @@map("skills") +} + +// Explicit Join Table based on ERD +model ProjectSkill { + projectId String @map("project_id") @db.Uuid + skillId String @map("skill_id") @db.Uuid + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade) + + @@id([projectId, skillId]) + @@map("project_skills") +} + +model Message { + id String @id @default(uuid()) @db.Uuid + senderName String @map("sender_name") + senderEmail String @map("sender_email") + content String @db.Text + isRead Boolean @default(false) @map("is_read") + createdAt DateTime @default(now()) @map("created_at") + + @@map("messages") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..5cb65ad --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,34 @@ +import { PrismaClient } from "@prisma/client"; +import { hash } from "bcryptjs"; + +const prisma = new PrismaClient(); + +async function main() { + console.log("Seeding database..."); + + const adminEmail = process.env.ADMIN_EMAIL || "admin@ando.dev"; + const rawPassword = process.env.ADMIN_PASSWORD || "admin123"; + const passwordHash = await hash(rawPassword, 12); + + // Upsert ensures we don't insert duplicate admins if the seeder runs twice + const user = await prisma.user.upsert({ + where: { email: adminEmail }, + update: {}, + create: { + email: adminEmail, + name: "Yolando Admin", + passwordHash, + }, + }); + + console.log(`Admin user created: ${user.email}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/[locale]/admin/dashboard/page.tsx b/src/app/[locale]/admin/dashboard/page.tsx new file mode 100644 index 0000000..81d241b --- /dev/null +++ b/src/app/[locale]/admin/dashboard/page.tsx @@ -0,0 +1,67 @@ +import { verifySession, clearSession } from "@/core/security/session"; +import { redirect } from "@/i18n/routing"; +import { getLocale } from "next-intl/server"; +import { LogOut } from "lucide-react"; + +export default async function DashboardPage() { + const session = await verifySession(); + const locale = await getLocale(); + + if (!session) { + redirect({ href: "/admin/login", locale }); + } + + return ( +
+ {/* Navbar Minimal Dashboard */} +
+
+
+

Admin Dashboard

+ {session.email} +
+ +
{ + "use server"; + await clearSession(); + redirect({ href: "/admin/login", locale }); + }}> + +
+
+
+ + {/* Main Content */} +
+
+ {/* Card: Projects */} +
+

Projects

+

Manage portfolio projects and case studies.

+
--
+
+ + {/* Card: Skills */} +
+

Tech Stack

+

Update your skills and technical arsenal.

+
--
+
+ + {/* Card: Messages */} +
+

Inbox

+

Read messages from visitors.

+
--
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/admin/login/page.tsx b/src/app/[locale]/admin/login/page.tsx new file mode 100644 index 0000000..50825d7 --- /dev/null +++ b/src/app/[locale]/admin/login/page.tsx @@ -0,0 +1,24 @@ +import { LoginForm } from "@/features/auth/login-form"; +import { verifySession } from "@/core/security/session"; +import { redirect } from "@/i18n/routing"; +import { getLocale } from "next-intl/server"; + +export default async function LoginPage() { + const session = await verifySession(); + const locale = await getLocale(); + + // If already logged in, redirect to dashboard + if (session) { + redirect({ href: "/admin/dashboard", locale }); + } + + return ( +
+
+ +
+ +
+
+ ); +} diff --git a/src/core/db/prisma.ts b/src/core/db/prisma.ts new file mode 100644 index 0000000..ba52e49 --- /dev/null +++ b/src/core/db/prisma.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from "@prisma/client"; + +const prismaClientSingleton = () => { + return new PrismaClient(); +}; + +declare global { + var prismaGlobal: undefined | ReturnType; +} + +export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); + +if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma; diff --git a/src/core/security/session.ts b/src/core/security/session.ts new file mode 100644 index 0000000..5c71417 --- /dev/null +++ b/src/core/security/session.ts @@ -0,0 +1,54 @@ +import { jwtVerify, SignJWT } from "jose"; +import { cookies } from "next/headers"; + +const secretKey = process.env.JWT_SECRET || "fallback-secret-for-development"; +const key = new TextEncoder().encode(secretKey); + +export const SESSION_COOKIE = "ando_admin_session"; + +export async function encrypt(payload: any) { + return await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("24h") // 1 day session + .sign(key); +} + +export async function decrypt(input: string): Promise { + try { + const { payload } = await jwtVerify(input, key, { + algorithms: ["HS256"], + }); + return payload; + } catch (error) { + return null; + } +} + +export async function createSession(userId: string, email: string) { + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + const session = await encrypt({ userId, email, expires }); + + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE, session, { + expires, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); +} + +export async function clearSession() { + const cookieStore = await cookies(); + cookieStore.delete(SESSION_COOKIE); +} + +export async function verifySession() { + const cookieStore = await cookies(); + const session = cookieStore.get(SESSION_COOKIE)?.value; + + if (!session) return null; + + return await decrypt(session); +} diff --git a/src/features/auth/actions.ts b/src/features/auth/actions.ts new file mode 100644 index 0000000..bbd2626 --- /dev/null +++ b/src/features/auth/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import { prisma } from "@/core/db/prisma"; +import { createSession } from "@/core/security/session"; +import { loginSchema, LoginFormValues } from "./login-schema"; +import { compare } from "bcryptjs"; +import { getLocale } from "next-intl/server"; + +export type ActionResponse = { + success: boolean; + message?: string; +}; + +export async function loginAction(data: LoginFormValues): Promise { + try { + // 1. Validate Input + const validatedData = loginSchema.safeParse(data); + if (!validatedData.success) { + return { success: false, message: "Invalid credentials format" }; + } + + const { email, password } = validatedData.data; + + // 2. Find User + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return { success: false, message: "Invalid email or password" }; + } + + // 3. Verify Password + const passwordMatch = await compare(password, user.passwordHash); + + if (!passwordMatch) { + return { success: false, message: "Invalid email or password" }; + } + + // 4. Create Session Cookie + await createSession(user.id, user.email); + + return { success: true }; + } catch (error) { + console.error("Login failed:", error); + return { success: false, message: "An unexpected error occurred" }; + } +} diff --git a/src/features/auth/login-form.tsx b/src/features/auth/login-form.tsx new file mode 100644 index 0000000..1f7360a --- /dev/null +++ b/src/features/auth/login-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { loginAction } from "./actions"; +import { useRouter } from "@/i18n/routing"; +import { Lock, Mail, Loader2 } from "lucide-react"; + +export function LoginForm() { + const router = useRouter(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + const formData = new FormData(e.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + const res = await loginAction({ email, password }); + + if (!res.success) { + setError(res.message || "Failed to login"); + setLoading(false); + } else { + // Successfully authenticated, redirect to dashboard + router.push("/admin/dashboard"); + } + } + + return ( +
+
+ +
+
+ +
+

Admin Portal

+

+ Sign in to manage your portfolio +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/features/auth/login-schema.ts b/src/features/auth/login-schema.ts new file mode 100644 index 0000000..b0130ee --- /dev/null +++ b/src/features/auth/login-schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + password: z.string().min(6, { message: "Password must be at least 6 characters" }), +}); + +export type LoginFormValues = z.infer; diff --git a/src/middleware.ts b/src/middleware.ts index 0718a56..6ebe652 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,53 @@ import createMiddleware from 'next-intl/middleware'; -import {routing} from './i18n/routing'; +import { routing } from './i18n/routing'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { jwtVerify } from 'jose'; -export default createMiddleware(routing); +const intlMiddleware = createMiddleware(routing); + +export async function middleware(request: NextRequest) { + // Execute internationalization middleware first + const response = intlMiddleware(request); + + // Pathname before rewrite (e.g., /id/admin/dashboard, /admin/dashboard) + const pathname = request.nextUrl.pathname; + + // Protect all /admin routes except the login page + const isAdminRoute = pathname.includes('/admin') && !pathname.includes('/admin/login'); + + if (isAdminRoute) { + const session = request.cookies.get('ando_admin_session')?.value; + + // Determine current locale from path or cookie for redirect + const segments = pathname.split('/'); + const localeIndex = routing.locales.includes(segments[1] as any) ? 1 : -1; + const locale = localeIndex !== -1 ? segments[localeIndex] : routing.defaultLocale; + + if (!session) { + return NextResponse.redirect(new URL(`/${locale}/admin/login`, request.url)); + } + + try { + const secretKey = process.env.JWT_SECRET || "fallback-secret-for-development"; + const key = new TextEncoder().encode(secretKey); + await jwtVerify(session, key, { algorithms: ["HS256"] }); + } catch (err) { + // Token is expired or invalid + const res = NextResponse.redirect(new URL(`/${locale}/admin/login`, request.url)); + res.cookies.delete('ando_admin_session'); + return res; + } + } + + return response; +} export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(id|en)/:path*'] + // Apply middleware to all standard routes and admin routes + matcher: [ + '/', + '/(id|en)/:path*', + '/admin/:path*' + ] };