diff --git a/next.config.ts b/next.config.ts index 5e891cf..ab5526a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,26 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'kakaocdn.net', + }, + { + protocol: 'https', + hostname: 'githubusercontent.com', + }, + { + protocol: 'https', + hostname: 'googleusercontent.com', + }, + { + protocol: 'https', + hostname: 'pstatic.net', + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 8034af6..5606cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,29 @@ "name": "local-commu", "version": "0.1.0", "dependencies": { + "@auth/prisma-adapter": "^2.7.4", + "@hookform/resolvers": "^4.1.1", + "@prisma/client": "^6.4.1", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", "next": "15.1.7", + "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-icons": "^5.5.0", "tailwind-merge": "^3.0.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -29,6 +40,7 @@ "postcss": "^8", "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", + "prisma": "^6.4.1", "tailwindcss": "^3.4.1", "typescript": "^5" } @@ -44,13 +56,499 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.37.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", + "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "@types/cookie": "0.6.0", + "cookie": "0.7.1", + "jose": "^5.9.3", + "oauth4webapi": "^3.0.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.7.4.tgz", + "integrity": "sha512-3T/X94R9J1sxOLQtsD3ijIZ0JGHPXlZQxRr/8NpnZBJ3KGxun/mNsZ1MwMRhTxy0mmn9JWXk7u9+xCcVn0pu3A==", + "dependencies": { + "@auth/core": "0.37.4" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.37.4", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.4.tgz", + "integrity": "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw==", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^5.9.6", + "oauth4webapi": "^3.1.1", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -172,6 +670,17 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.1.tgz", + "integrity": "sha512-S9YN1RgNWG+klUz5uQaV6rjE4pr6Py2tamj7ekshzLcMyg+/Pal1KZAYgGszV0+doiy41dUiQgXL3uRS9stndQ==", + "dependencies": { + "caniuse-lite": "^1.0.30001698" + }, + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -809,6 +1318,14 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -818,6 +1335,72 @@ "node": ">=14" } }, + "node_modules/@prisma/client": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.4.1.tgz", + "integrity": "sha512-A7Mwx44+GVZVexT5e2GF/WcKkEkNNKbgr059xpr5mn+oUm2ZW1svhe+0TRNBwCdzhfIZ+q23jEgsNPvKD9u+6g==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.4.1.tgz", + "integrity": "sha512-Q9xk6yjEGIThjSD8zZegxd5tBRNHYd13GOIG0nLsanbTXATiPXCLyvlYEfvbR2ft6dlRsziQXfQGxAgv7zcMUA==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.4.1.tgz", + "integrity": "sha512-KldENzMHtKYwsOSLThghOIdXOBEsfDuGSrxAZjMnimBiDKd3AE4JQ+Kv+gBD/x77WoV9xIPf25GXMWffXZ17BA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.4.1", + "@prisma/engines-version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", + "@prisma/fetch-engine": "6.4.1", + "@prisma/get-platform": "6.4.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d.tgz", + "integrity": "sha512-Xq54qw55vaCGrGgIJqyDwOq0TtjZPJEWsbQAHugk99hpDf2jcEeQhUcF+yzEsSqegBaDNLA4IC8Nn34sXmkiTQ==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.4.1.tgz", + "integrity": "sha512-uZ5hVeTmDspx7KcaRCNoXmcReOD+84nwlO2oFvQPRQh9xiFYnnUKDz7l9bLxp8t4+25CsaNlgrgilXKSQwrIGQ==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.4.1", + "@prisma/engines-version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", + "@prisma/get-platform": "6.4.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.4.1.tgz", + "integrity": "sha512-gXqZaDI5scDkBF8oza7fOD3Q3QMD0e0rBynlzDDZdTWbWmzjuW58PRZtj+jkvKje2+ZigCWkH8SsWZAsH6q1Yw==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.4.1" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -832,6 +1415,58 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", @@ -874,6 +1509,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -914,7 +1560,7 @@ "version": "19.0.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", - "dev": true, + "devOptional": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -1444,6 +2090,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "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", @@ -1697,6 +2351,14 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1788,7 +2450,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, + "devOptional": true, "dependencies": { "ms": "^2.1.3" }, @@ -2075,6 +2737,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "devOptional": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "devOptional": true, + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3435,6 +4149,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "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", @@ -3662,7 +4384,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "devOptional": true }, "node_modules/mz": { "version": "2.7.0", @@ -3750,6 +4472,32 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.25", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz", + "integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==", + "dependencies": { + "@auth/core": "0.37.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0-0", + "nodemailer": "^6.6.5", + "react": "^18.2.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -3785,6 +4533,14 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.0.tgz", + "integrity": "sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4201,6 +4957,26 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4303,6 +5079,40 @@ } } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, + "node_modules/prisma": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.4.1.tgz", + "integrity": "sha512-q2uJkgXnua/jj66mk6P9bX/zgYJFI/jn4Yp0aS6SPRrjH/n6VyOV7RDe1vHD0DX8Aanx4MvgmUPPoYnR6MJnPg==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "6.4.1", + "esbuild": ">=0.12 <1", + "esbuild-register": "3.6.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4361,6 +5171,29 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5349,7 +6182,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5606,6 +6439,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 3ddac1a..86bbac7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "prisma generate && next build", "start": "next start", "lint": "next lint", "prepare": "husky" @@ -13,18 +13,29 @@ "*.{ts,tsx}": "eslint --fix" }, "dependencies": { + "@auth/prisma-adapter": "^2.7.4", + "@hookform/resolvers": "^4.1.1", + "@prisma/client": "^6.4.1", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", "next": "15.1.7", + "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-icons": "^5.5.0", "tailwind-merge": "^3.0.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -34,6 +45,7 @@ "postcss": "^8", "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", + "prisma": "^6.4.1", "tailwindcss": "^3.4.1", "typescript": "^5" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..bc7f1f7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,45 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client{ + provider = "prisma-client-js" +} + +enum UserRole{ + ADMIN + USER +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? @map("email_verified") + image String? + password String? + role UserRole @default(USER) + accounts Account[] + @@map("users") +} + +model Account { + id String @id @default(cuid()) + userId String @map("user_id") + type String + provider String + providerAccountId String @map("provider_account_id") + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} diff --git a/src/actions/login.ts b/src/actions/login.ts new file mode 100644 index 0000000..8529763 --- /dev/null +++ b/src/actions/login.ts @@ -0,0 +1,41 @@ +'use server'; + +import { z } from 'zod'; +import { loginSchema } from '@/schemas'; +import { signIn } from '@/auth'; +import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; +import { AuthError } from 'next-auth'; +import UserDao from '@/dao/user'; + +export async function login(values: z.infer) { + const validatedFields = loginSchema.safeParse(values); + + if (!validatedFields.success) return { error: '유효하지 않은 필드입니다.' }; + + const { email, password } = validatedFields.data; + + const existingUser = await UserDao.getUserByEmail(email); + if (!existingUser || !existingUser.email || !existingUser.password) { + return { error: '회원가입한 이력이 없습니다.' }; + } + + try { + await signIn('credentials', { + email, + password, + redirectTo: DEFAULT_LOGIN_REDIRECT, + }); + + return { success: '로그인을 성공했습니다!' }; + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return { error: '로그인 정보를 올바르게 입력해주세요.' }; + default: + return { error: '알 수 없는 오류가 발생했습니다.' }; + } + } + throw error; + } +} diff --git a/src/actions/signup.ts b/src/actions/signup.ts new file mode 100644 index 0000000..6b177f3 --- /dev/null +++ b/src/actions/signup.ts @@ -0,0 +1,34 @@ +'use server'; + +import bcrypt from 'bcryptjs'; +import { db } from '@/lib/db'; + +import { z } from 'zod'; +import { signupSchema } from '@/schemas'; +import UserDao from '@/dao/user'; + +export async function signup(values: z.infer) { + const validatedFields = signupSchema.safeParse(values); + + if (!validatedFields.success) return { error: '유효하지 않은 필드입니다.' }; + + const { email, password, name } = validatedFields.data; + const hashedPassword = await bcrypt.hash( + password, + Number(process.env.NEXT_PUBLIC_SOLT!), + ); + + const existingUser = await UserDao.getUserByEmail(email); + + if (existingUser) return { error: '이미 가입한 사용자입니다.' }; + + await db.user.create({ + data: { + name, + email, + password: hashedPassword, + }, + }); + + return { success: '회원가입이 완료되었습니다!' }; +} diff --git a/src/app/(auth)/auth/error/page.tsx b/src/app/(auth)/auth/error/page.tsx new file mode 100644 index 0000000..e99994e --- /dev/null +++ b/src/app/(auth)/auth/error/page.tsx @@ -0,0 +1,5 @@ +import ErrorCard from '@/components/auth/ErrorCard'; + +export default function AuthError() { + return ; +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..1d6815b --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e5645eb --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,10 @@ +import LoginForm from '@/components/auth/LoginForm'; +import { Suspense } from 'react'; + +export default function Login() { + return ( + + + + ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..7c0ac08 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,5 @@ +import SignupForm from '@/components/auth/SignupForm'; + +export default function Register() { + return ; +} diff --git a/src/app/(protected)/mypage/page.tsx b/src/app/(protected)/mypage/page.tsx new file mode 100644 index 0000000..fbb6432 --- /dev/null +++ b/src/app/(protected)/mypage/page.tsx @@ -0,0 +1,19 @@ +import { auth, signOut } from '@/auth'; + +export default async function MyPage() { + const session = await auth(); + + return ( +
+ {JSON.stringify(session)} +
{ + 'use server'; + await signOut({ redirectTo: '/login' }); + }} + > + +
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..fae4907 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/auth'; diff --git a/src/app/globals.css b/src/app/globals.css index 88ede11..8e5c091 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,10 @@ @tailwind components; @tailwind utilities; +html, body, :root{ + height: 100%; +} + body, input::placeholder, textarea::placeholder { diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..f8fa9b8 --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,47 @@ +import type { NextAuthConfig } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import bcrypt from 'bcryptjs'; + +import { loginSchema } from '@/schemas'; +import UserDao from '@/dao/user'; + +import Github from 'next-auth/providers/github'; +import Google from 'next-auth/providers/google'; +import Kakao from 'next-auth/providers/kakao'; +import Naver from 'next-auth/providers/naver'; + +export default { + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + Github({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + }), + Kakao({ + clientId: process.env.KAKAO_CLIENT_ID, + clientSecret: process.env.KAKAO_CLIENT_SECRET, + }), + Naver({ + clientId: process.env.NAVER_CLIENT_ID, + clientSecret: process.env.NAVER_CLIENT_SECRET, + }), + Credentials({ + async authorize(credentials) { + const validatedFields = loginSchema.safeParse(credentials); + if (validatedFields.success) { + const { email, password } = validatedFields.data; + + const user = await UserDao.getUserByEmail(email); + if (!user || !user.password) return null; + + const passwordsMatch = await bcrypt.compare(password, user.password); + if (passwordsMatch) return user; + } + return null; + }, + }), + ], +} satisfies NextAuthConfig; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..795f419 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,42 @@ +import NextAuth from 'next-auth'; +import authConfig from '@/auth.config'; +import { PrismaAdapter } from '@auth/prisma-adapter'; +import { db } from '@/lib/db'; +import UserDao from '@/dao/user'; +import { UserRole } from '@prisma/client'; + +export const { + auth, + signIn, + signOut, + handlers: { GET, POST }, +} = NextAuth({ + adapter: PrismaAdapter(db), + session: { strategy: 'jwt' }, + events: { + async linkAccount({ user }) { + UserDao.updateEmailVerified(user.id ?? ''); + }, + }, + pages: { + signIn: '/login', + error: '/auth/error', + }, + callbacks: { + async session({ token, session }) { + if (token.sub && session.user) session.user.id = token.sub; + + if (token.role && session.user) + session.user.role = token.role as UserRole; + return session; + }, + async jwt({ token }) { + if (!token.sub) return token; + const existingUser = await UserDao.getUserById(token.sub); + if (!existingUser) return token; + token.role = existingUser.role; + return token; + }, + }, + ...authConfig, +}); diff --git a/src/components/auth/BackButton.tsx b/src/components/auth/BackButton.tsx new file mode 100644 index 0000000..67eb58e --- /dev/null +++ b/src/components/auth/BackButton.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; + +interface BackButtonProps { + href: string; + label: string; +} + +export default function BackButton({ href, label }: BackButtonProps) { + return ( + + ); +} diff --git a/src/components/auth/CardWrapper.tsx b/src/components/auth/CardWrapper.tsx new file mode 100644 index 0000000..142bfb8 --- /dev/null +++ b/src/components/auth/CardWrapper.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { ReactNode } from 'react'; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from '@/components/ui/card'; +import Header from '@/components/auth/Header'; +import Social from '@/components/auth/Social'; +import BackButton from '@/components/auth/BackButton'; + +interface CardWrapperProps { + children: ReactNode; + headerLabel: string; + backButtonLabel: string; + backButtonHref: string; + showSocial?: boolean; +} + +export default function CardWrapper({ + children, + headerLabel, + backButtonHref, + backButtonLabel, + showSocial, +}: CardWrapperProps) { + return ( + + +
+ + {children} + {showSocial && ( + + + + )} + + + + + ); +} diff --git a/src/components/auth/ErrorCard.tsx b/src/components/auth/ErrorCard.tsx new file mode 100644 index 0000000..7a6cd06 --- /dev/null +++ b/src/components/auth/ErrorCard.tsx @@ -0,0 +1,16 @@ +import CardWrapper from '@/components/auth/CardWrapper'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; + +export default function ErrorCard() { + return ( + +
+ +
+
+ ); +} diff --git a/src/components/auth/Header.tsx b/src/components/auth/Header.tsx new file mode 100644 index 0000000..cf73def --- /dev/null +++ b/src/components/auth/Header.tsx @@ -0,0 +1,12 @@ +interface HeaderProps { + label: string; +} + +export default function Header({ label }: HeaderProps) { + return ( +
+

LocalCommu

+

{label}

+
+ ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..6191459 --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { loginSchema } from '@/schemas'; +import { useForm } from 'react-hook-form'; + +import CardWrapper from '@/components/auth/CardWrapper'; +import { + // + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { FormSuccess, FormError } from '@/components/auth/formStatus'; +import { login } from '@/actions/login'; +import { useState, useTransition } from 'react'; + +import { useSearchParams } from 'next/navigation'; + +export default function LoginForm() { + const searchParams = useSearchParams(); + const urlError = + searchParams.get('error') === 'OAuthAccountNotLinked' + ? '다른 소셜 계정에서 사용된 이메일입니다.' + : ''; + + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const form = useForm>({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + }, + mode: 'onBlur', + }); + + const onSubmit = (value: z.infer) => { + setError(''); + setSuccess(''); + startTransition(() => { + login(value).then((data) => { + setError(data?.error ?? ''); + setSuccess(data?.success ?? ''); + }); + }); + }; + + return ( + +
+ +
+ { + return ( + + Email + + + + + + ); + }} + /> + { + return ( + + Password + + + + + + ); + }} + /> +
+ + + + + +
+ ); +} diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx new file mode 100644 index 0000000..83439d5 --- /dev/null +++ b/src/components/auth/SignupForm.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { signupSchema } from '@/schemas'; +import { useForm } from 'react-hook-form'; + +import CardWrapper from '@/components/auth/CardWrapper'; +import { + // + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { signup } from '@/actions/signup'; +import { useState, useTransition } from 'react'; +import { FormSuccess, FormError } from './formStatus'; + +export default function RegisterForm() { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const form = useForm>({ + resolver: zodResolver(signupSchema), + defaultValues: { + email: '', + name: '', + password: '', + }, + mode: 'onBlur', + }); + + const onSubmit = (value: z.infer) => { + setError(''); + setSuccess(''); + startTransition(() => { + signup(value).then((data) => { + setError(data.error ?? ''); + setSuccess(data.success ?? ''); + }); + }); + }; + + return ( + +
+ +
+ { + return ( + + Email + + + + + + ); + }} + /> + { + return ( + + name + + + + + + ); + }} + /> + { + return ( + + Password + + + + + + ); + }} + /> +
+ + + + + +
+ ); +} diff --git a/src/components/auth/Social.tsx b/src/components/auth/Social.tsx new file mode 100644 index 0000000..5f39b9d --- /dev/null +++ b/src/components/auth/Social.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { signIn } from 'next-auth/react'; + +import { FcGoogle } from 'react-icons/fc'; +import { FaGithub } from 'react-icons/fa'; +import { RiKakaoTalkFill } from 'react-icons/ri'; +import { SiNaver } from 'react-icons/si'; + +import { Button } from '@/components/ui/button'; +import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; + +export default function Social() { + const onClick = (provider: 'google' | 'github' | 'kakao' | 'naver') => { + signIn(provider, { + callbackUrl: DEFAULT_LOGIN_REDIRECT, + }); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/src/components/auth/formStatus/FormError.tsx b/src/components/auth/formStatus/FormError.tsx new file mode 100644 index 0000000..3ebe4c2 --- /dev/null +++ b/src/components/auth/formStatus/FormError.tsx @@ -0,0 +1,16 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; + +interface FormErrorProps { + message?: string; +} + +export default function FormError({ message }: FormErrorProps) { + if (!message) return null; + + return ( +
+ +

{message}

+
+ ); +} diff --git a/src/components/auth/formStatus/FormSuccess.tsx b/src/components/auth/formStatus/FormSuccess.tsx new file mode 100644 index 0000000..b67faa9 --- /dev/null +++ b/src/components/auth/formStatus/FormSuccess.tsx @@ -0,0 +1,16 @@ +import { CheckCircledIcon } from '@radix-ui/react-icons'; + +interface FormSuccessProps { + message?: string; +} + +export default function FormSuccess({ message }: FormSuccessProps) { + if (!message) return null; + + return ( +
+ +

{message}

+
+ ); +} diff --git a/src/components/auth/formStatus/index.ts b/src/components/auth/formStatus/index.ts new file mode 100644 index 0000000..46d1808 --- /dev/null +++ b/src/components/auth/formStatus/index.ts @@ -0,0 +1,2 @@ +export { default as FormSuccess } from './FormSuccess'; +export { default as FormError } from './FormError'; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..a9a6a6a --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..c3daa38 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +