diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d556876d..8720dfb0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,7 +62,8 @@ "donjayamanne.githistory", "ms-azuretools.vscode-docker", "ftonato.password-generator", - "adamhartford.vscode-base64" + "adamhartford.vscode-base64", + "ChakrounAnas.turbo-console-log" ] } }, diff --git a/.env b/.env index b9d751a3..952e1fe9 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" PASSWORD_HASHER_SECRET=secret -JWT_SECRET=secret \ No newline at end of file +JWT_SECRET=secret +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=secret \ No newline at end of file diff --git a/env.mjs b/env.mjs index 2170b8da..39cfe800 100644 --- a/env.mjs +++ b/env.mjs @@ -10,6 +10,8 @@ export const env = createEnv({ PASSWORD_HASHER_SECRET: z.string().nonempty().min(16), JWT_SECRET: z.string().nonempty().min(16), DATABASE_URL: z.string().nonempty(), + NEXTAUTH_SECRET: z.string().nonempty().min(16), + NEXTAUTH_URL: z.string().nonempty(), }, client: {}, runtimeEnv: { @@ -17,5 +19,7 @@ export const env = createEnv({ PASSWORD_HASHER_SECRET: process.env.PASSWORD_HASHER_SECRET, JWT_SECRET: process.env.JWT_SECRET, DATABASE_URL: process.env.DATABASE_URL, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, }, }) diff --git a/package-lock.json b/package-lock.json index fe73ed10..1a06d5b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.0", "hasInstallScript": true, "dependencies": { + "@auth/prisma-adapter": "^1.0.1", "@hookform/resolvers": "^3.1.1", + "@next-auth/prisma-adapter": "^1.0.7", "@next/bundle-analyzer": "^13.3.0", "@prisma/client": "^5.0.0", "@radix-ui/react-accordion": "^1.1.1", @@ -42,11 +44,13 @@ "class-variance-authority": "^0.6.1", "clsx": "^2.0.0", "crypto-js": "^4.1.1", + "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lucide-react": "^0.263.0", "next": "^13.4.10", "next-auth": "^4.22.3", "next-compose-plugins": "^2.2.1", + "pino": "^8.14.1", "prisma": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -148,6 +152,63 @@ "node": ">=6.0.0" } }, + "node_modules/@auth/core": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.9.0.tgz", + "integrity": "sha512-W2WO0WCBg1T3P8+yjQPzurTQhPv6ecBYfJ2oE3uvXPAX5ZLWAMSjKFAIa9oLZy5pwrB+YehJZPnlIxVilhrVcg==", + "dependencies": { + "@panva/hkdf": "^1.0.4", + "cookie": "0.5.0", + "jose": "^4.11.1", + "oauth4webapi": "^2.0.6", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/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/@auth/core/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/@auth/core/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/@auth/prisma-adapter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.1.tgz", + "integrity": "sha512-sBp9l/jVr7l9y7rp2Pv6eoP7i8X2CgRNE3jDWJ0B/u+HnKRofXflD1cldPqRSAkJhqH3UxhVtMTEijT9FoofmQ==", + "dependencies": { + "@auth/core": "0.9.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4" + } + }, "node_modules/@aw-web-design/x-default-browser": { "version": "1.4.126", "resolved": "https://registry.npmjs.org/@aw-web-design/x-default-browser/-/x-default-browser-1.4.126.tgz", @@ -4062,6 +4123,15 @@ "tar-fs": "^2.1.1" } }, + "node_modules/@next-auth/prisma-adapter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz", + "integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==", + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3", + "next-auth": "^4" + } + }, "node_modules/@next/bundle-analyzer": { "version": "13.4.7", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-13.4.7.tgz", @@ -13521,7 +13591,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -14307,6 +14376,14 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", @@ -14830,7 +14907,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -15236,7 +15312,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, "funding": [ { "type": "github", @@ -19049,7 +19124,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "engines": { "node": ">=6" } @@ -19058,7 +19132,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -19334,6 +19407,14 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-redact": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.2.0.tgz", + "integrity": "sha512-zaTadChr+NekyzallAMXATXLOR8MNx3zqpZ0MUF2aGf4EathnG0f32VLODNlY8IuGY3HoRO2L6/6fSzNsLaHIw==", + "engines": { + "node": ">=6" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -21057,7 +21138,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -26213,6 +26293,11 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -31230,6 +31315,14 @@ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" }, + "node_modules/oauth4webapi": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.3.0.tgz", + "integrity": "sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==", + "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", @@ -31373,6 +31466,11 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz", + "integrity": "sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -32211,6 +32309,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pino": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.14.1.tgz", + "integrity": "sha512-8LYNv7BKWXSfS+k6oEc6occy5La+q2sPwU3q2ljTX5AZk7v+5kND2o5W794FyRaqha6DJajmkNRsWtPpFyMUdw==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "v1.0.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^2.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.1.0", + "thread-stream": "^2.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz", + "integrity": "sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", + "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -32855,7 +33011,6 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "engines": { "node": ">= 0.6.0" } @@ -32877,6 +33032,11 @@ "node": ">=8" } }, + "node_modules/process-warning": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.2.0.tgz", + "integrity": "sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -33214,6 +33374,11 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -33783,6 +33948,14 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.23.3", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.3.tgz", @@ -34419,6 +34592,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -35169,6 +35350,14 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.3.0.tgz", + "integrity": "sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -36332,6 +36521,14 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.3.0.tgz", + "integrity": "sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/thriftrw": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/thriftrw/-/thriftrw-3.12.0.tgz", diff --git a/package.json b/package.json index a5e51f97..a0426982 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|.storybook/|.eslintrc.js' --image graph.svg" }, "dependencies": { + "@auth/prisma-adapter": "^1.0.1", "@hookform/resolvers": "^3.1.1", + "@next-auth/prisma-adapter": "^1.0.7", "@next/bundle-analyzer": "^13.3.0", "@prisma/client": "^5.0.0", "@radix-ui/react-accordion": "^1.1.1", @@ -57,11 +59,13 @@ "class-variance-authority": "^0.6.1", "clsx": "^2.0.0", "crypto-js": "^4.1.1", + "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lucide-react": "^0.263.0", "next": "^13.4.10", "next-auth": "^4.22.3", "next-compose-plugins": "^2.2.1", + "pino": "^8.14.1", "prisma": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -124,6 +128,6 @@ "node": ">=18.15.0" }, "prisma": { - "schema": "src/prisma/schema.prisma" + "schema": "prisma/schema.prisma" } } diff --git a/prisma/migrations/20230722093841_init/migration.sql b/prisma/migrations/20230722093841_init/migration.sql new file mode 100644 index 00000000..c6d3ce12 --- /dev/null +++ b/prisma/migrations/20230722093841_init/migration.sql @@ -0,0 +1,71 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + "username" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230722094738_add_password/migration.sql b/prisma/migrations/20230722094738_add_password/migration.sql new file mode 100644 index 00000000..2f77ec76 --- /dev/null +++ b/prisma/migrations/20230722094738_add_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "password" TEXT; diff --git a/src/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml similarity index 100% rename from src/prisma/migrations/migration_lock.toml rename to prisma/migrations/migration_lock.toml diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..6a09357e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,61 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + 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]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + + // Custom fields + username String @unique + role String @default("user") + password String? +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth].ts b/src/app/api/auth/[...nextauth]/route.ts similarity index 50% rename from src/app/api/auth/[...nextauth].ts rename to src/app/api/auth/[...nextauth]/route.ts index 0380d358..59ed89ef 100644 --- a/src/app/api/auth/[...nextauth].ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -2,4 +2,5 @@ import NextAuth from "next-auth" import { nextAuthOptions } from "@/lib/auth/index" -export default NextAuth(nextAuthOptions) +const handler = NextAuth(nextAuthOptions) +export { handler as GET, handler as POST } diff --git a/src/app/api/health.ts b/src/app/api/health.ts deleted file mode 100644 index 05fda7d6..00000000 --- a/src/app/api/health.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next" - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - res.status(200).json({ status: "ok" }) -} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 00000000..fd342545 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return new Response("OK") +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts new file mode 100644 index 00000000..29e376d9 --- /dev/null +++ b/src/app/api/register/route.ts @@ -0,0 +1,49 @@ +import { Prisma } from "@prisma/client" +import { NextResponse } from "next/server" +import * as z from "zod" +import { hash } from "@/lib/bcrypt" +import { logger } from "@/lib/logger" +import { prisma } from "@/lib/prisma" +import { ApiError } from "@/lib/utils" +import { signUpSchema } from "@/types/auth" + +export async function POST(req: Request) { + try { + const data = (await req.json()) as z.infer + const { username, email, password } = signUpSchema.parse(data) + const hashedPassword = await hash(password, 12) + + const user = await prisma.user.create({ + data: { + username, + email: email.toLowerCase(), + password: hashedPassword, + }, + }) + + return NextResponse.json({ + user: { + username: user.username, + email: user.email, + }, + }) + } catch (error: unknown) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2002") { + const meta = error.meta + if (!meta) return ApiError("Account already exists", { status: 400 }) + if ((meta.target as Array).includes("email")) { + return ApiError("Email already exists", { status: 400 }) + } else if ((meta.target as Array).includes("username")) { + return ApiError("Username already exists", { status: 400 }) + } + } + } + logger.error(error) + if (error instanceof Error) { + return ApiError(error.message, { status: 500 }) + } else { + return ApiError("An unknown error occurred", { status: 500 }) + } + } +} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts new file mode 100644 index 00000000..6736b42e --- /dev/null +++ b/src/app/api/session/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { nextAuthOptions } from "@/lib/auth" + +export async function GET() { + const session = await getServerSession(nextAuthOptions) + + if (!session) { + return new NextResponse(JSON.stringify({ status: "fail", message: "You are not logged in" }), { status: 401 }) + } + + return NextResponse.json({ + authenticated: !!session, + session, + }) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 20c90f45..8a270c60 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next" import React from "react" import "./globals.css" +import { NextAuthProvider } from "@/components/auth/provider" import { Toaster } from "@/components/ui/toaster" export const metadata: Metadata = { @@ -12,8 +13,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - {children} - + + {children} + + ) diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 5de06db9..3a49b635 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,5 +1,14 @@ +import Link from "next/link" import React from "react" +import { buttonVariants } from "@/components/ui/button" export default function Page404() { - return

Not found

+ return ( +
+

Page not found

+ + Home + +
+ ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 9db7f820..d5542905 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,29 @@ +import Link from "next/link" +import { buttonVariants } from "@/components/ui/button" + export default function Home() { - return

Hello world

+ return ( +
+

Hello World

+ +
+ ) } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 00000000..c334e1eb --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,29 @@ +import { getServerSession } from "next-auth" +import requireAuth from "@/components/auth/require-auth" +import SignoutButton from "@/components/auth/sign-out-button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { nextAuthOptions } from "@/lib/auth" + +export default async function Profile() { + await requireAuth("/profile") + const session = await getServerSession(nextAuthOptions) + const user = session?.user + + return ( +
+ + + Profile + + + {!user ?

Loading...

:
{JSON.stringify(session, null, 2)}
} +
+ +
+ +
+
+
+
+ ) +} diff --git a/src/app/sign-in/layout.tsx b/src/app/sign-in/layout.tsx new file mode 100644 index 00000000..50e035cc --- /dev/null +++ b/src/app/sign-in/layout.tsx @@ -0,0 +1,10 @@ +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "Sign-in", + description: "Login to your account", +} + +export default function SigninLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx new file mode 100644 index 00000000..1627908d --- /dev/null +++ b/src/app/sign-in/page.tsx @@ -0,0 +1,59 @@ +import Link from "next/link" +import { LoginUserAuthForm } from "@/components/auth/login-user-auth-form" +import { Icons } from "@/components/icons" +import { Button, buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +export default function SignInPage({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined } +}) { + return ( +
+ + Sign up + +
+
+
+
+
+
+

Login to your account

+

Enter your details below.

+
+
+ +
+
+ +
+
+ Or continue with +
+
+ +
+

+ By clicking continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+
+
+
+ ) +} diff --git a/src/app/sign-up/credentials/page.tsx b/src/app/sign-up/credentials/page.tsx index 50877c8c..7b2a2b09 100644 --- a/src/app/sign-up/credentials/page.tsx +++ b/src/app/sign-up/credentials/page.tsx @@ -1,3 +1,27 @@ -export default function SignupByCredentials() { - return <>W +import { redirect } from "next/navigation" +import { RegisterUserAuthForm } from "@/components/auth/register-user-auth-form" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export default function SignupByCredentials({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined } +}) { + //? If there is no email in the search params, redirect to the sign-up page + if (!searchParams?.email) { + redirect("/sign-up") + } + + return ( +
+ + + Create an account + + + + + +
+ ) } diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx index 76616a9b..66ca1120 100644 --- a/src/app/sign-up/page.tsx +++ b/src/app/sign-up/page.tsx @@ -4,11 +4,15 @@ import { Icons } from "@/components/icons" import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" -export default function SignUpPage() { +export default function SignUpPage({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined } +}) { return (
Login @@ -23,7 +27,7 @@ export default function SignUpPage() {

Enter your email below to create your account

- +
diff --git a/src/components/auth/login-user-auth-form.tsx b/src/components/auth/login-user-auth-form.tsx new file mode 100644 index 00000000..01978924 --- /dev/null +++ b/src/components/auth/login-user-auth-form.tsx @@ -0,0 +1,112 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import { signIn } from "next-auth/react" +import * as React from "react" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { logger } from "@/lib/logger" +import { cn } from "@/lib/utils" +import { signInSchema } from "@/types/auth" +import { Button } from "../ui/button" +import { Form } from "../ui/form" +import FormField from "../ui/form-field" +import { Label } from "../ui/label" +import { toast } from "../ui/use-toast" + +type UserAuthFormProps = React.HTMLAttributes & { + searchParams: { [key: string]: string | string[] | undefined } +} + +export const formSchema = signInSchema + +export type IForm = z.infer + +export function LoginUserAuthForm({ searchParams, ...props }: UserAuthFormProps) { + const router = useRouter() + + const callbackUrl = searchParams?.callbackUrl?.toString() || "/profile" + + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }) + + async function onSubmit(data: IForm) { + setIsLoading(true) + try { + const res = await signIn("credentials", { + redirect: false, + email: data.email, + password: data.password, + callbackUrl, + }) + if (!res?.error) { + router.push(callbackUrl) + } else { + throw new Error("Invalid credentials. Please try again.") + } + } catch (error) { + logger.error(error) + if (error instanceof Error) { + toast({ + title: "Error", + description: error.message, + variant: "destructive", + }) + } else { + toast({ + title: "Error", + description: "An unknown error occurred", + variant: "destructive", + }) + } + } + setIsLoading(false) + } + + return ( +
+ +
+ + +
+
+ + +
+ +
+ + ) +} diff --git a/src/components/auth/provider.tsx b/src/components/auth/provider.tsx new file mode 100644 index 00000000..5ef0d72c --- /dev/null +++ b/src/components/auth/provider.tsx @@ -0,0 +1,11 @@ +"use client" + +import { SessionProvider } from "next-auth/react" + +type Props = { + children?: React.ReactNode +} + +export const NextAuthProvider = ({ children }: Props) => { + return {children} +} diff --git a/src/components/auth/register-user-auth-form.tsx b/src/components/auth/register-user-auth-form.tsx index 8ee5d948..42795c9f 100644 --- a/src/components/auth/register-user-auth-form.tsx +++ b/src/components/auth/register-user-auth-form.tsx @@ -1,13 +1,14 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" +import Link from "next/link" import { useRouter } from "next/navigation" import * as React from "react" - import { useForm } from "react-hook-form" import * as z from "zod" +import { cn, handleFetch } from "@/lib/utils" import { signUpSchema } from "@/types/auth" -import { Button } from "../ui/button" +import { Button, buttonVariants } from "../ui/button" import { Form } from "../ui/form" import FormField from "../ui/form-field" import { Label } from "../ui/label" @@ -15,11 +16,23 @@ import { toast } from "../ui/use-toast" type UserAuthFormProps = React.HTMLAttributes & { isMinimized?: boolean + searchParams?: { [key: string]: string | string[] | undefined } } -export const formSchema = signUpSchema.extend({ - confirmPassword: z.string(), -}) +export const formSchema = signUpSchema + .extend({ + confirmPassword: z.string(), + }) + .superRefine((data, ctx) => { + if (data.password !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Passwords don't match", + path: ["confirmPassword"], + fatal: true, + }) + } + }) export const formMinizedSchema = signUpSchema.pick({ email: true, @@ -30,105 +43,162 @@ export const getFormSchema = (isMinimized?: boolean) => (isMinimized ? formMiniz export type IForm = z.infer export type IFormMinimized = z.infer -export function RegisterUserAuthForm({ isMinimized, ...props }: UserAuthFormProps) { - const [isLoading, setIsLoading] = React.useState(false) +export function RegisterUserAuthForm({ isMinimized, searchParams, ...props }: UserAuthFormProps) { const router = useRouter() + const emailFromSearchParam = searchParams?.email?.toString() + + const [isLoading, setIsLoading] = React.useState(false) + //? This state is used to avoid a useEffect to set the email value + const [emailSettedBySearchParam, setEmailSettedBySearchParam] = React.useState( + searchParams?.email?.toString() + ) + const form = useForm({ resolver: zodResolver(getFormSchema(isMinimized)), defaultValues: { - email: "", + email: emailFromSearchParam || "", username: "", password: "", + confirmPassword: "", }, }) - function onSubmit(data: IForm | IFormMinimized) { + //? If the emailSettedBySearchParam is not the same as the email value, and different from the previous value, set the email value + if ( + emailFromSearchParam && + emailFromSearchParam !== emailSettedBySearchParam && + emailFromSearchParam !== form.getValues("email") + ) { + form.setValue("email", emailFromSearchParam) + setEmailSettedBySearchParam(emailFromSearchParam) + } + + async function onSubmit(data: IForm | IFormMinimized) { if (isMinimized) { - return router.push("/sign-up/credentials") + const searchParams = new URLSearchParams() + searchParams.set("email", data.email) + return router.push("/sign-up/credentials?" + searchParams.toString()) + } + //? Verify if data is IForm + if (!("username" in data)) { + return } setIsLoading(true) - toast({ - title: "Success", - description: ( -
-
{JSON.stringify(data, null, 2)}
-
- ), + const request = fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }) + const res = await handleFetch(request, (error) => { + if (error === "Email already exists") { + return form.setError("email", { + type: "manual", + message: "Email already exists", + }) + } else if (error === "Username already exists") { + return form.setError("username", { + type: "manual", + message: "Username already exists", + }) + } + toast({ + title: "Error", + description: error, + variant: "destructive", + }) + }) + if (res) { + router.push("/profile") + } setIsLoading(false) } return (
- -
-
- + +
+ +
+ {!isMinimized && ( + + Edit + + )}
- {!isMinimized && ( - <> -
- - -
-
- - -
-
- - -
- - )} -
+ {!isMinimized && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )} + ) diff --git a/src/components/auth/require-auth.tsx b/src/components/auth/require-auth.tsx new file mode 100644 index 00000000..2ace8cb5 --- /dev/null +++ b/src/components/auth/require-auth.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation" +import { getServerSession } from "next-auth" +import { nextAuthOptions } from "@/lib/auth" + +export default async function requireAuth(callbackUrl?: string) { + const session = await getServerSession(nextAuthOptions) + if (!session?.user) { + let searchParams = "" + if (callbackUrl) { + searchParams = "?" + new URLSearchParams({ callbackUrl }).toString() + } + redirect("/sign-in" + searchParams) + } +} diff --git a/src/components/auth/sign-out-button.tsx b/src/components/auth/sign-out-button.tsx new file mode 100644 index 00000000..a63a69b5 --- /dev/null +++ b/src/components/auth/sign-out-button.tsx @@ -0,0 +1,12 @@ +"use client" + +import { signOut } from "next-auth/react" +import { Button } from "../ui/button" + +export default function SignoutButton() { + return ( + + ) +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index fc6fa0e1..bb87fb0f 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -141,8 +141,8 @@ export const Icons = { ), @@ -151,8 +151,8 @@ export const Icons = { ), diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 00000000..47dc8358 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +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< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + 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-field.tsx b/src/components/ui/form-field.tsx index 92b75848..0e2fe450 100644 --- a/src/components/ui/form-field.tsx +++ b/src/components/ui/form-field.tsx @@ -1,6 +1,5 @@ import { HTMLInputTypeAttribute } from "react" -import { ControllerRenderProps, Path, UseFormReturn } from "react-hook-form" -import * as z from "zod" +import { ControllerRenderProps, FieldPath, FieldValues, UseFormReturn } from "react-hook-form" import { FormControl, FormDescription, @@ -18,9 +17,12 @@ export type InputWithOmittedProps = Omit< "form" | "type" | "defaultValue" | "id" > -export interface FormFieldProps extends InputWithOmittedProps { - form: UseFormReturn> - name: Path> +export interface FormFieldProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> extends InputWithOmittedProps { + form: UseFormReturn + name: TName label?: string placeholder?: string description?: string @@ -29,18 +31,30 @@ export interface FormFieldProps extends InputWithOmitted className?: string } -function getInner( - { field, autoComplete, placeholder, type, form }: FormFieldProps & { field: ControllerRenderProps> }, +function getInner< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>( + { + field, + autoComplete, + placeholder, + type, + form, + }: FormFieldProps & { field: ControllerRenderProps }, props: InputWithOmittedProps ) { - if (type === "password") { + if (type === "password-eye-slash") { return } else { return } } -export default function FormField({ +export default function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ form, name, label, @@ -50,7 +64,7 @@ export default function FormField({ autoComplete, className, ...props -}: FormFieldProps) { +}: FormFieldProps) { return ( { const creds = await signInSchema.parseAsync(credentials) - const user = await prisma.user.findFirst({ + if (!creds.email || !creds.password) { + return null + } + + const user = await prisma.user.findUnique({ where: { email: creds.email }, }) @@ -28,7 +33,12 @@ export const nextAuthOptions: NextAuthOptions = { return null } - const isValidPassword = await bcryptCompare(user.password, creds.password) + if (!user.password) { + //? this should happen if the user signed up with a provider + return null + } + + const isValidPassword = await bcryptCompare(creds.password, user.password) if (!isValidPassword) { return null @@ -47,10 +57,20 @@ export const nextAuthOptions: NextAuthOptions = { if (user) { token.id = user.id token.email = user.email + if ("username" in user) token.username = user.username } return token }, + session: async ({ session, token }) => { + return { + ...session, + user: { + ...session.user, + id: token.id, + }, + } + }, }, jwt: { secret: env.JWT_SECRET, @@ -60,4 +80,7 @@ export const nextAuthOptions: NextAuthOptions = { signIn: "/sign-in", newUser: "/sign-up", }, + session: { + strategy: "jwt", + }, } diff --git a/src/lib/auth/requireAuth.ts b/src/lib/auth/requireAuth.ts deleted file mode 100644 index 51104a18..00000000 --- a/src/lib/auth/requireAuth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { GetServerSideProps, GetServerSidePropsContext } from "next" -import { getServerSession } from "next-auth" - -import { nextAuthOptions } from "../auth" - -export const requireAuth = (func: GetServerSideProps) => async (ctx: GetServerSidePropsContext) => { - const session = await getServerSession(ctx.req, ctx.res, nextAuthOptions) - - if (!session) { - return { - redirect: { - destination: "/sign-in", // login path - permanent: false, - }, - } - } - - return await func(ctx) -} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 00000000..b2917b08 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,5 @@ +import pino from 'pino'; + +export const logger = pino({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', +}); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 3f1fa5c1..cfa5a45d 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,3 +1,11 @@ import { PrismaClient } from "@prisma/client" -export const prisma = new PrismaClient() +const globalForPrisma = global as unknown as { prisma: PrismaClient } + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: ["query"], + }) + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ec79801f..092bb071 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,52 @@ import { type ClassValue, clsx } from "clsx" +import { NextResponse } from "next/server" import { twMerge } from "tailwind-merge" - +import { IApiError } from "@/types" +import { logger } from "./logger" + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +function isApiError(error: unknown): error is IApiError { + return typeof error === "object" && error !== null && "status" in error && "message" in error +} + +export async function handleFetch( + fetch: Promise, + onError: (error: string) => void = (error) => logger.error(error) +): Promise { + try { + const response = await fetch + if (!response.ok) { + let data: unknown + try { + data = await response.json() + } catch (error: unknown) { + throw new Error(response.statusText) + } + if (isApiError(data)) { + throw new Error(data.message) + } else { + throw new Error(response.statusText) + } + } + const data = await response.json() + return data + } catch (error: unknown) { + logger.error(error) + if (error instanceof Error) { + onError(error.message) + } else { + onError("An unknown error occurred") + } + } +} + +export function ApiError(message: string, init?: ResponseInit | undefined): NextResponse { + const content: IApiError = { + status: "error", + message: message, + } + return new NextResponse(JSON.stringify(content), init) +} diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index fea74cda..00000000 --- a/src/middleware.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from "next-auth/middleware" - -export const config = { matcher: ["/auth"] } diff --git a/src/prisma/migrations/20230721160351_init/migration.sql b/src/prisma/migrations/20230721160351_init/migration.sql deleted file mode 100644 index 900aa0fc..00000000 --- a/src/prisma/migrations/20230721160351_init/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "username" TEXT NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma deleted file mode 100644 index 9f72d180..00000000 --- a/src/prisma/schema.prisma +++ /dev/null @@ -1,20 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - password String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} \ No newline at end of file diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts deleted file mode 100644 index 1b465370..00000000 --- a/src/server/routers/_app.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod" -import { procedure, router } from "../trpc" - -export const appRouter = router({ - hello: procedure - .input( - z.object({ - text: z.string(), - }) - ) - .query((opts) => { - return { - greeting: `hello ${opts.input.text}`, - } - }), -}) - -// export type definition of API -export type AppRouter = typeof appRouter diff --git a/src/server/trpc.ts b/src/server/trpc.ts deleted file mode 100644 index 446b5095..00000000 --- a/src/server/trpc.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { initTRPC } from "@trpc/server" - -// Avoid exporting the entire t-object -// since it's not very descriptive. -// For instance, the use of a t variable -// is common in i18n libraries. -const t = initTRPC.create() - -// Base router and procedure helpers -export const router = t.router -export const procedure = t.procedure diff --git a/src/types/auth.ts b/src/types/auth.ts index 27429dc5..26012460 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,6 +1,15 @@ +import { DefaultSession } from "next-auth" import * as z from "zod" -export const passwordSchema = z.string().min(4).max(16) +export const passwordSchema = z + .string() + .min(4, "Password must be at least 4 characters long") + .max(16, "Password must be at most 16 characters long") + +export const passwordSchemaWithRegex = passwordSchema.regex( + /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*]).{8,}$/, + "Password must contain at least one uppercase letter, one lowercase letter, and one number" +) export const signInSchema = z.object({ email: z.string().email(), @@ -9,7 +18,13 @@ export const signInSchema = z.object({ export const signUpSchema = signInSchema.extend({ username: z.string(), + password: passwordSchemaWithRegex, }) export type ISignIn = z.infer export type ISignUp = z.infer + +export type Session = { + id?: unknown + email?: string | null +} & DefaultSession diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..98f83dce --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export type IApiError = { + status: "error" + message: string +}