diff --git a/.env.sample b/.env.sample index 928735d..904bf2c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,16 @@ +# ----------------------------------------------------------------------------- +# App Host - Don't add "/" in the end of the url (same in production) +# ----------------------------------------------------------------------------- +NEXT_PUBLIC_HOST_URL= + # Environment variables declared in this file are automatically made available to Prisma. # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. # See the documentation for all the connection string options: https://pris.ly/d/connection-strings DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" + +# NextAuth.js +AUTH_SECRET= + +DISCOUSE_SECRET= \ No newline at end of file diff --git a/package.json b/package.json index f0f887c..cc527b3 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,17 @@ "prepare": "husky" }, "dependencies": { + "@auth/prisma-adapter": "^2.4.2", "@prisma/client": "^5.19.0", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "lucide-react": "^0.437.0", "next": "14.2.7", + "next-auth": "5.0.0-beta.20", "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", @@ -31,6 +35,7 @@ }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.3.1", + "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81fc381..28f7a4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@auth/prisma-adapter': + specifier: ^2.4.2 + version: 2.4.2(@prisma/client@5.19.0(prisma@5.19.0)) '@prisma/client': specifier: ^5.19.0 version: 5.19.0(prisma@5.19.0) @@ -17,18 +20,27 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 clsx: specifier: ^2.1.1 version: 2.1.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 lucide-react: specifier: ^0.437.0 version: 0.437.0(react@18.3.1) next: specifier: 14.2.7 version: 14.2.7(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next-auth: + specifier: 5.0.0-beta.20 + version: 5.0.0-beta.20(next@14.2.7(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -48,6 +60,9 @@ importers: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.3.1 version: 4.3.1(prettier@3.3.3) + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/node': specifier: ^20 version: 20.16.2 @@ -98,6 +113,25 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@auth/core@0.34.2': + resolution: {integrity: sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==} + 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 + + '@auth/prisma-adapter@2.4.2': + resolution: {integrity: sha512-QQwnGYfDiyTcAxMVhTrim+lLFFA3TKq3nIrbPtGZXlkiuNQ5t0rUg//Km7Wv21pD5bxhy4aRPlfq7TdFKk3XIw==} + peerDependencies: + '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5' + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -322,6 +356,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -550,6 +587,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-toast@1.2.1': + resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==} + 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 + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -604,6 +654,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.1.0': + resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} + 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 + '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} @@ -616,6 +679,12 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -881,10 +950,17 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1490,6 +1566,9 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + jose@5.8.0: + resolution: {integrity: sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1638,6 +1717,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.20: + resolution: {integrity: sha512-+48SjV9k9AtUU3JbEIa4PXNjKIewfFjVGL7Xs2RKkuQ5QqegDNIQiIG8sLk6/qo7RTScQYIGKgeQ5IuQRtrTQg==} + 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 + next-themes@0.3.0: resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} peerDependencies: @@ -1673,6 +1768,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + oauth4webapi@2.12.1: + resolution: {integrity: sha512-7lgyz74z5NJ1dRbZSnL/YCJt7UMhNOqQ8I6Jokh/Et66GVK6KfFikzU98i5PTbgbrsSmxzsQprJdI3Z+5Uwj2Q==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1839,6 +1937,14 @@ packages: resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@5.2.3: + resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} + peerDependencies: + preact: '>=10' + + preact@10.11.3: + resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1903,6 +2009,9 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + prisma@5.19.0: resolution: {integrity: sha512-Pu7lUKpVyTx8cVwM26dYh8NdvMOkMnJXzE8L6cikFuR4JwyMU5NKofQkWyxJKlTT4fNjmcnibTvklV8oVMrn+g==} engines: {node: '>=16.13'} @@ -2341,6 +2450,25 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@auth/core@0.34.2': + dependencies: + '@panva/hkdf': 1.2.1 + '@types/cookie': 0.6.0 + cookie: 0.6.0 + jose: 5.8.0 + oauth4webapi: 2.12.1 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + + '@auth/prisma-adapter@2.4.2(@prisma/client@5.19.0(prisma@5.19.0))': + dependencies: + '@auth/core': 0.34.2 + '@prisma/client': 5.19.0(prisma@5.19.0) + transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' + - nodemailer + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -2590,6 +2718,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -2808,6 +2938,26 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-toast@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.5)(react@18.3.1)': dependencies: react: 18.3.1 @@ -2848,6 +2998,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/rect@1.1.0': {} '@rushstack/eslint-patch@1.10.4': {} @@ -2859,6 +3018,10 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.7.0 + '@types/cookie@0.6.0': {} + + '@types/crypto-js@4.2.2': {} + '@types/json5@0.0.29': {} '@types/node@20.16.2': @@ -3155,12 +3318,16 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.6.0: {} + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -3930,6 +4097,8 @@ snapshots: jiti@1.21.6: {} + jose@5.8.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -4073,6 +4242,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.20(next@14.2.7(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + dependencies: + '@auth/core': 0.34.2 + next: 14.2.7(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -4111,6 +4286,8 @@ snapshots: dependencies: path-key: 4.0.0 + oauth4webapi@2.12.1: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -4263,6 +4440,13 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 + preact-render-to-string@5.2.3(preact@10.11.3): + dependencies: + preact: 10.11.3 + pretty-format: 3.8.0 + + preact@10.11.3: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.6.6(@ianvs/prettier-plugin-sort-imports@4.3.1(prettier@3.3.3))(prettier@3.3.3): @@ -4273,6 +4457,8 @@ snapshots: prettier@3.3.3: {} + pretty-format@3.8.0: {} + prisma@5.19.0: dependencies: '@prisma/engines': 5.19.0 diff --git a/prisma/migrations/20240907101534_init/migration.sql b/prisma/migrations/20240907101534_init/migration.sql deleted file mode 100644 index 5a669b3..0000000 --- a/prisma/migrations/20240907101534_init/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- CreateTable -CREATE TABLE "users" ( - "id" TEXT NOT NULL, - "name" TEXT, - "email" TEXT NOT NULL, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/migrations/20240907132157_init/migration.sql b/prisma/migrations/20240907132157_init/migration.sql new file mode 100644 index 0000000..34dfa18 --- /dev/null +++ b/prisma/migrations/20240907132157_init/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "avatarUrl" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a622ae5..a6aa808 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,10 +7,21 @@ datasource db { url = env("DATABASE_URL") } -model User { - id String @id @default(cuid()) - name String? - email String @unique - - @@map("users") +enum UserRole { + ADMIN + USER +} + +model User { + id String @id @default(cuid()) + username String @unique + email String @unique + name String? + avatarUrl String? + role UserRole @default(USER) + + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @map(name: "updated_at") + + @@map(name: "users") } diff --git a/src/actions/user-authorize.ts b/src/actions/user-authorize.ts new file mode 100644 index 0000000..68d3f0c --- /dev/null +++ b/src/actions/user-authorize.ts @@ -0,0 +1,7 @@ +"use server"; + +import { signIn as nextSignIn } from "@/auth"; + +export async function signIn(data: Record) { + return nextSignIn("credentials", data); +} diff --git a/src/app/page.tsx b/src/app/()/page.tsx similarity index 73% rename from src/app/page.tsx rename to src/app/()/page.tsx index 6895e2c..b8a68f1 100644 --- a/src/app/page.tsx +++ b/src/app/()/page.tsx @@ -1,16 +1,7 @@ -import { prisma } from "@/lib/prisma"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/theme-toggle"; -export const dynamic = "force-dynamic"; - -async function getAllUsers() { - return await prisma.user.findMany(); -} - export default async function IndexPage() { - const users = await getAllUsers(); - return ( <>
@@ -26,9 +17,6 @@ export default async function IndexPage() {

Hello, Next js & Shadcn UI & Next Auth


- {users.map((user) => ( -
{user.id}
- ))} diff --git a/src/app/(auth)/authorize/page.tsx b/src/app/(auth)/authorize/page.tsx new file mode 100644 index 0000000..5271b5f --- /dev/null +++ b/src/app/(auth)/authorize/page.tsx @@ -0,0 +1,53 @@ +import { Suspense } from "react"; +import { Metadata } from "next"; +import Link from "next/link"; +import { MessageCircleCode } from "lucide-react"; + +import { UserAuthorize } from "@/components/auth/user-authorize"; + +type Props = { + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export const metadata: Metadata = { + title: `Auth – 数字牧民社区`, + description: "Sign in to your account", +}; + +export default function AuthPage({ searchParams }: Props) { + return ( +
+
+
+ +
+ Welcome to{" "} + 数字牧民社区 +
+
+
+ + + +
+

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

+
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..deb4b39 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + + if (user) { + if (user.role === "ADMIN") redirect("/admin"); + redirect("/dashboard"); + } + + return
{children}
; +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..0c99545 --- /dev/null +++ b/src/app/(auth)/sign-in/page.tsx @@ -0,0 +1,61 @@ +import { Suspense } from "react"; +import { Metadata } from "next"; +import Link from "next/link"; +import { ChevronLeft, MessageCircleCode } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { UserAuthForm } from "@/components/auth/user-auth-form"; + +export const metadata: Metadata = { + title: "Login", + description: "Login to your account", +}; + +export default function LoginPage() { + return ( +
+ + <> + + Back + + +
+
+ +
+ Welcome to{" "} + 数字牧民社区 +
+
+ + + +

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

+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..0c6cd67 --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,26 @@ +export default function DashboardPage() { + return ( +
+ {/* Sidebar Navigation */} + + + {/* Main Content Area */} +
+

Welcome to Your Dashboard

+

Select an option from the menu to get started.

+
+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..ae409bb --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + + return
{children}
; +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..fa39f10 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; // Referring to the auth.ts we just created + +export const { GET, POST } = handlers; diff --git a/src/app/api/auth/discourse/route.ts b/src/app/api/auth/discourse/route.ts new file mode 100644 index 0000000..19b530a --- /dev/null +++ b/src/app/api/auth/discourse/route.ts @@ -0,0 +1,21 @@ +import { cookies } from "next/headers"; +import Hex from "crypto-js/enc-hex"; +import hmacSHA256 from "crypto-js/hmac-sha256"; +import WordArray from "crypto-js/lib-typedarrays"; + +import { AUTH_NONCE } from "@/lib/constants"; + +const hostUrl = process.env.NEXT_PUBLIC_HOST_URL as string; +const clientSecret = process.env.DISCOUSE_SECRET as string; + +export async function POST(_req: Request) { + const nonce = WordArray.random(16).toString(); + const return_url = `${hostUrl}/authorize`; + const sso = btoa(`nonce=${nonce}&return_sso_url=${return_url}`); + const sig = hmacSHA256(sso, clientSecret).toString(Hex); + + cookies().set(AUTH_NONCE, nonce, { maxAge: 60 * 10 }); + return Response.json({ + sso_url: `https://shuzimumin.com/session/sso_provider?sso=${sso}&sig=${sig}`, + }); +} diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..e87fd83 --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,79 @@ +import { cookies } from "next/headers"; +import { UserRole } from "@prisma/client"; +import Hex from "crypto-js/enc-hex"; +import hmacSHA256 from "crypto-js/hmac-sha256"; +import type { NextAuthConfig } from "next-auth"; +import Credentials from "next-auth/providers/credentials"; + +import { AUTH_NONCE } from "@/lib/constants"; +import { createUser, getUserById, updateUser } from "@/lib/dto/user"; + +// Notice this is only an object, not a full Auth.js instance +export default { + providers: [ + Credentials({ + credentials: { + sso: {}, + sig: {}, + }, + authorize: async (credentials) => { + const DISCOUSE_SECRET = process.env.DISCOUSE_SECRET as string; + const sso = credentials.sso as string; + const sig = credentials.sig; + // 校验数据正确性 + if (hmacSHA256(sso, DISCOUSE_SECRET).toString(Hex) != sig) { + throw new Error("Request params is invalid (code: -1001)."); + } + // 校验 nonce + const cookieStore = cookies(); + let searchParams = new URLSearchParams(atob(sso as string)); + const nonce = searchParams.get("nonce"); + if (!cookieStore.has(AUTH_NONCE) || !nonce) { + throw new Error("Request params is invalid (code: -1002)."); + } + if (cookieStore.get(AUTH_NONCE)?.value != nonce) { + throw new Error("Request params is invalid (code: -1003)."); + } + cookieStore.delete(AUTH_NONCE); + + const id = searchParams.get("external_id"); + const email = searchParams.get("email"); + const username = searchParams.get("username"); + const name = searchParams.get("name"); + const avatarUrl = searchParams.get("avatar_url"); + const isAdmin = searchParams.get("admin") == "true"; + if (!id || !email || !username) { + throw new Error("User not found."); + } + + // 查数据库是否有人 + let dbUser = await getUserById(id); + if (dbUser) { + // 更新 + dbUser = await updateUser(id, { + username, + email, + name, + avatarUrl, + role: isAdmin ? UserRole.ADMIN : UserRole.USER, + updatedAt: new Date(), + }); + } else { + // 创建 + dbUser = await createUser({ + id, + username, + email, + name, + avatarUrl, + role: isAdmin ? UserRole.ADMIN : UserRole.USER, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + return dbUser; + }, + }), + ], +} satisfies NextAuthConfig; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..17f715e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,56 @@ +import { PrismaAdapter } from "@auth/prisma-adapter"; +import NextAuth from "next-auth"; + +import authConfig from "./auth.config"; +import { getUserById } from "./lib/dto/user"; +import { prisma } from "./lib/prisma"; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: PrismaAdapter(prisma), + session: { strategy: "jwt" }, + pages: { + signIn: "/sign-in", + }, + callbacks: { + async session({ token, session }) { + if (!session.user) { + return session; + } + + if (token.sub) { + session.user.id = token.sub; + } + if (token.username) { + session.user.username = token.username; + } + if (token.email) { + session.user.email = token.email; + } + if (token.picture) { + session.user.avatarUrl = token.picture; + } + if (token.role) { + session.user.role = token.role; + } + + session.user.name = token.name; + return session; + }, + async jwt({ token }) { + if (!token.sub) return token; + + const dbUser = await getUserById(token.sub); + if (!dbUser) return token; + + token.username = dbUser.username; + token.email = dbUser.email; + token.picture = dbUser.avatarUrl; + token.name = dbUser.name; + token.role = dbUser.role; + + return token; + }, + }, + ...authConfig, + debug: process.env.NODE_ENV !== "production", +}); diff --git a/src/components/auth/user-auth-form.tsx b/src/components/auth/user-auth-form.tsx new file mode 100644 index 0000000..91df547 --- /dev/null +++ b/src/components/auth/user-auth-form.tsx @@ -0,0 +1,60 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, MessageCircleCode } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; +import { buttonVariants } from "@/components/ui/button"; + +interface DiscourseData { + sso_url: string; +} + +export function UserAuthForm({ + className, + ...props +}: React.HTMLAttributes) { + const [isLoading, setIsLoading] = React.useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const signIn = () => { + React.startTransition(async () => { + const response = await fetch("/api/auth/discourse", { method: "POST" }); + if (!response.ok || response.status !== 200) { + setIsLoading(false); + toast({ + variant: "destructive", + title: "内部服务异常", + description: response.statusText, + }); + } else { + let data: DiscourseData = await response.json(); + router.push(data.sso_url); + } + }); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/auth/user-authorize.tsx b/src/components/auth/user-authorize.tsx new file mode 100644 index 0000000..3a4cd6e --- /dev/null +++ b/src/components/auth/user-authorize.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { signIn } from "@/actions/user-authorize"; + +interface UserAuthorizeProps extends React.HTMLAttributes { + data: Record; +} + +export function UserAuthorize({ + className, + data, + ...props +}: UserAuthorizeProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const signInCallback = useCallback(async () => { + if (isLoading) { + return; + } + setIsLoading(true); + try { + await signIn({ ...data, redirectTo: "/dashboard" }); + setIsLoading(false); + } catch (error) { + setError(error); + setIsLoading(false); + } + }, []); + + useEffect(() => { + const timer = setTimeout(signInCallback, 5); + return () => { + clearTimeout(timer); + }; + }, []); + + return ( + <> + {error ? ( +

登录异常,授权失败!

+ ) : ( +

账号信息验证中,准备跳转中,请稍等...

+ )} + + ); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..0b671b7 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client"; + +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..364b797 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useToast } from "@/hooks/use-toast"; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..6555e79 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,191 @@ +"use client"; + +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..5be7333 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1 @@ +export const AUTH_NONCE = "oauth.nonce"; diff --git a/src/lib/dto/user.ts b/src/lib/dto/user.ts new file mode 100644 index 0000000..8460bd1 --- /dev/null +++ b/src/lib/dto/user.ts @@ -0,0 +1,40 @@ +import { User } from "@prisma/client"; + +import { prisma } from "../prisma"; + +export interface UpdateUserForm extends Omit {} + +export const getUserById = async (id: string) => { + try { + const user = await prisma.user.findUnique({ where: { id } }); + + return user; + } catch { + return null; + } +}; + +export const updateUser = async (userId: string, data: UpdateUserForm) => { + try { + const session = await prisma.user.update({ + where: { + id: userId, + }, + data, + }); + return session; + } catch (error) { + return null; + } +}; + +export const createUser = async (data: User) => { + try { + const session = await prisma.user.create({ + data, + }); + return session; + } catch (error) { + return null; + } +}; diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..eba6f94 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,12 @@ +import "server-only"; + +import { cache } from "react"; +import { auth } from "@/auth"; + +export const getCurrentUser = cache(async () => { + const session = await auth(); + if (!session?.user) { + return undefined; + } + return session.user; +}); diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..758f285 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,5 @@ +import NextAuth from "next-auth"; + +import authConfig from "./auth.config"; + +export const { auth: middleware } = NextAuth(authConfig); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..cb80056 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,23 @@ +import { UserRole } from "@prisma/client"; +import { User } from "next-auth"; +import { JWT } from "next-auth/jwt"; + +export type ExtendedUser = User & { + username?: string; + avatarUrl?: string; + role: UserRole; +}; + +declare module "next-auth/jwt" { + interface JWT { + username?: string; + avatarUrl?: string; + role: UserRole; + } +} + +declare module "next-auth" { + interface Session { + user: ExtendedUser; + } +}