feat: Sign in with discourse sso

This commit is contained in:
Tuluobo 2024-09-08 12:55:18 +08:00
parent 7af50b3003
commit c311aeb18e
27 changed files with 1122 additions and 29 deletions

View File

@ -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=

View File

@ -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",

186
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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");

View File

@ -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");

View File

@ -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")
}

View File

@ -0,0 +1,7 @@
"use server";
import { signIn as nextSignIn } from "@/auth";
export async function signIn(data: Record<string, any>) {
return nextSignIn("credentials", data);
}

View File

@ -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 (
<>
<header className="flex h-24 items-center justify-center">
@ -26,9 +17,6 @@ export default async function IndexPage() {
<h1>Hello, Next js & Shadcn UI & Next Auth</h1>
<br />
<Button>Start</Button>
{users.map((user) => (
<div key={user.id}>{user.id}</div>
))}
</div>
</main>
</>

View File

@ -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 (
<div className="container flex h-screen w-screen flex-col items-center justify-center">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<MessageCircleCode className="mx-auto size-12" />
<div className="text-2xl font-semibold tracking-tight">
<span>Welcome to</span>{" "}
<span style={{ fontFamily: "Bahamas Bold" }}></span>
</div>
</div>
<div>
<Suspense>
<UserAuthorize data={searchParams} />
</Suspense>
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link
href="/terms"
className="hover:text-brand underline underline-offset-4"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="hover:text-brand underline underline-offset-4"
>
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

18
src/app/(auth)/layout.tsx Normal file
View File

@ -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 <div className="min-h-screen">{children}</div>;
}

View File

@ -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 (
<div className="container flex h-screen w-screen flex-col items-center justify-center">
<Link
href="/"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"absolute left-4 top-4 md:left-8 md:top-8",
)}
>
<>
<ChevronLeft className="mr-2 size-4" />
Back
</>
</Link>
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<MessageCircleCode className="mx-auto size-12" />
<div className="text-2xl font-semibold tracking-tight">
<span>Welcome to</span>{" "}
<span style={{ fontFamily: "Bahamas Bold" }}></span>
</div>
</div>
<Suspense>
<UserAuthForm />
</Suspense>
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link
href="/terms"
className="hover:text-brand underline underline-offset-4"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="hover:text-brand underline underline-offset-4"
>
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
export default function DashboardPage() {
return (
<div className="flex h-screen">
{/* Sidebar Navigation */}
<nav className="w-64 bg-gray-800 p-4 text-white">
<h2 className="mb-4 text-2xl font-bold">Dashboard</h2>
<ul>
<li className="mb-2">
<a
href="/dashboard"
className="block rounded px-4 py-2 hover:bg-gray-700"
>
Home
</a>
</li>
</ul>
</nav>
{/* Main Content Area */}
<main className="flex-1 p-8">
<h1 className="mb-4 text-3xl font-bold">Welcome to Your Dashboard</h1>
<p>Select an option from the menu to get started.</p>
</main>
</div>
);
}

View File

@ -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 <div className="min-h-screen">{children}</div>;
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/auth"; // Referring to the auth.ts we just created
export const { GET, POST } = handlers;

View File

@ -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}`,
});
}

79
src/auth.config.ts Normal file
View File

@ -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;

56
src/auth.ts Normal file
View File

@ -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",
});

View File

@ -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<HTMLDivElement>) {
const [isLoading, setIsLoading] = React.useState<boolean>(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 (
<div className={cn("grid gap-3", className)} {...props}>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsLoading(true);
signIn();
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<MessageCircleCode className="mr-2 size-4" />
)}{" "}
</button>
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { signIn } from "@/actions/user-authorize";
interface UserAuthorizeProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>;
}
export function UserAuthorize({
className,
data,
...props
}: UserAuthorizeProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | unknown>(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 ? (
<p className="text-center"></p>
) : (
<p className="text-center">...</p>
)}
</>
);
}

129
src/components/ui/toast.tsx Normal file
View File

@ -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<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
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<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -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 (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

191
src/hooks/use-toast.ts Normal file
View File

@ -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<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
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<ToasterToast, "id">;
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<State>(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 };

1
src/lib/constants.ts Normal file
View File

@ -0,0 +1 @@
export const AUTH_NONCE = "oauth.nonce";

40
src/lib/dto/user.ts Normal file
View File

@ -0,0 +1,40 @@
import { User } from "@prisma/client";
import { prisma } from "../prisma";
export interface UpdateUserForm extends Omit<User, "id" | "createdAt"> {}
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;
}
};

12
src/lib/session.ts Normal file
View File

@ -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;
});

5
src/middleware.ts Normal file
View File

@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import authConfig from "./auth.config";
export const { auth: middleware } = NextAuth(authConfig);

23
src/types/next-auth.d.ts vendored Normal file
View File

@ -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;
}
}