mirror of
https://github.com/woodchen-ink/Q58Connect.git
synced 2025-07-18 14:01:55 +08:00
feat: Sign in with discourse sso
This commit is contained in:
parent
7af50b3003
commit
c311aeb18e
10
.env.sample
10
.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=
|
@ -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
186
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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");
|
22
prisma/migrations/20240907132157_init/migration.sql
Normal file
22
prisma/migrations/20240907132157_init/migration.sql
Normal 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");
|
@ -7,10 +7,21 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
name String?
|
||||
avatarUrl String?
|
||||
role UserRole @default(USER)
|
||||
|
||||
@@map("users")
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
7
src/actions/user-authorize.ts
Normal file
7
src/actions/user-authorize.ts
Normal 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);
|
||||
}
|
@ -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>
|
||||
</>
|
53
src/app/(auth)/authorize/page.tsx
Normal file
53
src/app/(auth)/authorize/page.tsx
Normal 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
18
src/app/(auth)/layout.tsx
Normal 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>;
|
||||
}
|
61
src/app/(auth)/sign-in/page.tsx
Normal file
61
src/app/(auth)/sign-in/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
src/app/(dashboard)/dashboard/page.tsx
Normal file
26
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
src/app/(dashboard)/layout.tsx
Normal file
14
src/app/(dashboard)/layout.tsx
Normal 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>;
|
||||
}
|
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth"; // Referring to the auth.ts we just created
|
||||
|
||||
export const { GET, POST } = handlers;
|
21
src/app/api/auth/discourse/route.ts
Normal file
21
src/app/api/auth/discourse/route.ts
Normal 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
79
src/auth.config.ts
Normal 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
56
src/auth.ts
Normal 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",
|
||||
});
|
60
src/components/auth/user-auth-form.tsx
Normal file
60
src/components/auth/user-auth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/components/auth/user-authorize.tsx
Normal file
48
src/components/auth/user-authorize.tsx
Normal 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
129
src/components/ui/toast.tsx
Normal 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,
|
||||
};
|
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal 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
191
src/hooks/use-toast.ts
Normal 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
1
src/lib/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const AUTH_NONCE = "oauth.nonce";
|
40
src/lib/dto/user.ts
Normal file
40
src/lib/dto/user.ts
Normal 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
12
src/lib/session.ts
Normal 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
5
src/middleware.ts
Normal 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
23
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user