feat(auth): Auth.js v5 with phone OTP login, send-otp API, login page and user state in nav
This commit is contained in:
parent
175276a085
commit
b7fbd5ac53
@ -14,12 +14,14 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"next-auth": "5.0.0-beta.31",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@ -35,6 +37,6 @@
|
|||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
pnpm-lock.yaml
generated
211
pnpm-lock.yaml
generated
@ -8,9 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@auth/prisma-adapter':
|
||||||
|
specifier: ^2.11.2
|
||||||
|
version: 2.11.2(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.19.3
|
specifier: ^6.19.3
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.0.2))(typescript@5.0.2)
|
version: 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3)
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -26,9 +29,12 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.2.6
|
specifier: 16.2.6
|
||||||
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
next-auth:
|
||||||
|
specifier: 5.0.0-beta.31
|
||||||
|
version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^6.19.3
|
specifier: ^6.19.3
|
||||||
version: 6.19.3(typescript@5.0.2)
|
version: 6.19.3(typescript@6.0.3)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@ -59,7 +65,7 @@ importers:
|
|||||||
version: 9.39.4(jiti@2.7.0)
|
version: 9.39.4(jiti@2.7.0)
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.2.6
|
specifier: 16.2.6
|
||||||
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
@ -67,8 +73,8 @@ importers:
|
|||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5
|
specifier: ^6.0.3
|
||||||
version: 5.0.2
|
version: 6.0.3
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -76,6 +82,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@auth/prisma-adapter@2.11.2':
|
||||||
|
resolution: {integrity: sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6'
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@ -619,6 +644,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@ -1726,6 +1754,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.3:
|
||||||
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
js-tokens@3.0.0:
|
js-tokens@3.0.0:
|
||||||
resolution: {integrity: sha512-poXEQHPMmTrYZuJgNRll2sbc3kJsSU1m/g1Q93IE6txNj3p6xOOOmdj1G/zCVGawYSPzTkSoWGg1otqbeqKJeg==}
|
resolution: {integrity: sha512-poXEQHPMmTrYZuJgNRll2sbc3kJsSU1m/g1Q93IE6txNj3p6xOOOmdj1G/zCVGawYSPzTkSoWGg1otqbeqKJeg==}
|
||||||
|
|
||||||
@ -1935,6 +1966,22 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31:
|
||||||
|
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
|
'@simplewebauthn/server': ^9.0.2
|
||||||
|
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
react: ^18.2.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
optional: true
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
next@16.2.6:
|
next@16.2.6:
|
||||||
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
|
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@ -1971,6 +2018,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6:
|
||||||
|
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2080,6 +2130,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11:
|
||||||
|
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.24.3:
|
||||||
|
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -2382,9 +2440,9 @@ packages:
|
|||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: '>=4.8.4 <6.1.0'
|
typescript: '>=4.8.4 <6.1.0'
|
||||||
|
|
||||||
typescript@5.0.2:
|
typescript@6.0.3:
|
||||||
resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==}
|
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
@ -2455,6 +2513,23 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
|
'@auth/core@0.41.2':
|
||||||
|
dependencies:
|
||||||
|
'@panva/hkdf': 1.2.1
|
||||||
|
jose: 6.2.3
|
||||||
|
oauth4webapi: 3.8.6
|
||||||
|
preact: 10.24.3
|
||||||
|
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||||
|
|
||||||
|
'@auth/prisma-adapter@2.11.2(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))':
|
||||||
|
dependencies:
|
||||||
|
'@auth/core': 0.41.2
|
||||||
|
'@prisma/client': 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@simplewebauthn/browser'
|
||||||
|
- '@simplewebauthn/server'
|
||||||
|
- nodemailer
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@ -2889,13 +2964,15 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.0.2))(typescript@5.0.2)':
|
'@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.19.3(typescript@5.0.2)
|
prisma: 6.19.3(typescript@6.0.3)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
|
|
||||||
'@prisma/config@6.19.3':
|
'@prisma/config@6.19.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3027,40 +3104,40 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)':
|
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
'@typescript-eslint/scope-manager': 8.59.2
|
'@typescript-eslint/scope-manager': 8.59.2
|
||||||
'@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.59.2
|
'@typescript-eslint/visitor-keys': 8.59.2
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
ts-api-utils: 2.5.0(typescript@5.0.2)
|
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)':
|
'@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.59.2
|
'@typescript-eslint/scope-manager': 8.59.2
|
||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.59.2
|
'@typescript-eslint/visitor-keys': 8.59.2
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/project-service@8.59.2(typescript@5.0.2)':
|
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@ -3069,47 +3146,47 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
'@typescript-eslint/visitor-keys': 8.59.2
|
'@typescript-eslint/visitor-keys': 8.59.2
|
||||||
|
|
||||||
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.0.2)':
|
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)':
|
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
ts-api-utils: 2.5.0(typescript@5.0.2)
|
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/types@8.59.2': {}
|
'@typescript-eslint/types@8.59.2': {}
|
||||||
|
|
||||||
'@typescript-eslint/typescript-estree@8.59.2(typescript@5.0.2)':
|
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/project-service': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
'@typescript-eslint/visitor-keys': 8.59.2
|
'@typescript-eslint/visitor-keys': 8.59.2
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
tinyglobby: 0.2.16
|
tinyglobby: 0.2.16
|
||||||
ts-api-utils: 2.5.0(typescript@5.0.2)
|
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)':
|
'@typescript-eslint/utils@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
|
||||||
'@typescript-eslint/scope-manager': 8.59.2
|
'@typescript-eslint/scope-manager': 8.59.2
|
||||||
'@typescript-eslint/types': 8.59.2
|
'@typescript-eslint/types': 8.59.2
|
||||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@ -3611,20 +3688,20 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-config-next@16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2):
|
eslint-config-next@16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 16.2.6
|
'@next/eslint-plugin-next': 16.2.6
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0))
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0))
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0))
|
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0))
|
||||||
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0))
|
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0))
|
||||||
globals: 16.4.0
|
globals: 16.4.0
|
||||||
typescript-eslint: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
typescript-eslint: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@typescript-eslint/parser'
|
- '@typescript-eslint/parser'
|
||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
@ -3650,22 +3727,22 @@ snapshots:
|
|||||||
tinyglobby: 0.2.16
|
tinyglobby: 0.2.16
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@ -3676,7 +3753,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0))
|
||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
is-core-module: 2.16.2
|
is-core-module: 2.16.2
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@ -3688,7 +3765,7 @@ snapshots:
|
|||||||
string.prototype.trimend: 1.0.9
|
string.prototype.trimend: 1.0.9
|
||||||
tsconfig-paths: 3.15.0
|
tsconfig-paths: 3.15.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- eslint-import-resolver-typescript
|
- eslint-import-resolver-typescript
|
||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
@ -4164,6 +4241,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.3: {}
|
||||||
|
|
||||||
js-tokens@3.0.0: {}
|
js-tokens@3.0.0: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@ -4324,6 +4403,12 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
next-auth@5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@auth/core': 0.41.2
|
||||||
|
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.6
|
'@next/env': 16.2.6
|
||||||
@ -4365,6 +4450,8 @@ snapshots:
|
|||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
tinyexec: 1.1.2
|
tinyexec: 1.1.2
|
||||||
|
|
||||||
|
oauth4webapi@3.8.6: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@ -4481,14 +4568,20 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.24.3
|
||||||
|
|
||||||
|
preact@10.24.3: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prisma@6.19.3(typescript@5.0.2):
|
prisma@6.19.3(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/config': 6.19.3
|
'@prisma/config': 6.19.3
|
||||||
'@prisma/engines': 6.19.3
|
'@prisma/engines': 6.19.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
@ -4799,9 +4892,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@5.0.2):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
|
|
||||||
tsconfig-paths@3.15.0:
|
tsconfig-paths@3.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -4856,18 +4949,18 @@ snapshots:
|
|||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
reflect.getprototypeof: 1.0.10
|
reflect.getprototypeof: 1.0.10
|
||||||
|
|
||||||
typescript-eslint@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2):
|
typescript-eslint@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2))(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
|
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||||
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
|
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
typescript: 5.0.2
|
typescript: 6.0.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
typescript@5.0.2: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
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 "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
59
src/app/api/auth/send-otp/route.ts
Normal file
59
src/app/api/auth/send-otp/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { getClientIp } from "@/lib/current-user";
|
||||||
|
import { getRedis } from "@/lib/redis";
|
||||||
|
import { ok, ERR } from "@/lib/api-response";
|
||||||
|
|
||||||
|
const Body = z.object({
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(/^1[3-9]\d{9}$/, "请输入有效的中国大陆手机号"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/send-otp
|
||||||
|
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 5 次
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const parsed = Body.safeParse(await req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误");
|
||||||
|
}
|
||||||
|
const { phone } = parsed.data;
|
||||||
|
|
||||||
|
// 限流
|
||||||
|
const phoneRl = await rateLimit(`otp:phone:${phone}`, 60, 1);
|
||||||
|
if (!phoneRl.allowed) {
|
||||||
|
return ERR.RATE_LIMITED();
|
||||||
|
}
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (ip) {
|
||||||
|
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 5);
|
||||||
|
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 6 位验证码
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
|
||||||
|
// 缓存到 Redis(5 分钟过期)
|
||||||
|
const redis = getRedis();
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(`sms:otp:${phone}`, code, "EX", 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO[团队]: 接入真实短信服务(阿里云 / 火山引擎 SMS)
|
||||||
|
// - 模板:${SMS_TEMPLATE_CODE}
|
||||||
|
// - 签名:${SMS_SIGN_NAME}
|
||||||
|
// - 参数:{ code, expireMin: 5 }
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.log(`[dev-otp] 发送给 ${phone}: ${code}(开发环境也接受万能码 123456)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ message: "验证码已发送", expiresIn: 300 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[POST /api/auth/send-otp]", e);
|
||||||
|
return ERR.INTERNAL();
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/app/login/LoginForm.tsx
Normal file
238
src/app/login/LoginForm.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { Phone, KeyRound, Loader2 } from "lucide-react";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const callbackUrl = sp.get("callbackUrl") || "/";
|
||||||
|
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
|
||||||
|
const codeValid = /^\d{6}$/.test(code);
|
||||||
|
|
||||||
|
const sendOtp = async () => {
|
||||||
|
if (!phoneValid) {
|
||||||
|
setError("请输入有效的中国大陆手机号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/send-otp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) throw new Error(data.error?.message || "发送失败");
|
||||||
|
setCountdown(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((c) => {
|
||||||
|
if (c <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return c - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "发送失败");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!phoneValid || !codeValid) {
|
||||||
|
setError("请检查手机号和验证码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await signIn("phone-otp", {
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
if (result?.error) {
|
||||||
|
setError("验证码错误或已失效");
|
||||||
|
} else {
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("登录失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-128px)] flex items-center justify-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Logo size="lg" href={null} />
|
||||||
|
<p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3">
|
||||||
|
Sign in to Vote
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleLogin}
|
||||||
|
className="bg-elevated/60 backdrop-blur-md border border-white/10 rounded-2xl p-6 sm:p-8 space-y-4 shadow-card"
|
||||||
|
>
|
||||||
|
{/* 手机号 */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
|
||||||
|
手机号
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="tel"
|
||||||
|
placeholder="138 0000 0000"
|
||||||
|
value={phone}
|
||||||
|
maxLength={11}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))
|
||||||
|
}
|
||||||
|
className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 验证码 */}
|
||||||
|
<div>
|
||||||
|
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
|
||||||
|
验证码
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<KeyRound
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={code}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
||||||
|
}
|
||||||
|
className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!phoneValid || countdown > 0 || sending}
|
||||||
|
onClick={sendOtp}
|
||||||
|
className={cn(
|
||||||
|
"h-11 px-4 rounded-lg font-display text-xs tracking-widest uppercase border transition-all whitespace-nowrap",
|
||||||
|
countdown > 0
|
||||||
|
? "bg-white/5 border-white/10 text-white/30"
|
||||||
|
: "bg-purple-500/10 border-purple-500/40 text-purple-300 hover:bg-purple-500/15 disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : countdown > 0 ? (
|
||||||
|
`${countdown}s`
|
||||||
|
) : (
|
||||||
|
"发送"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/30 text-pink-300 text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{process.env.NODE_ENV !== "production" && (
|
||||||
|
<div className="px-3 py-2 rounded-lg bg-purple-500/8 border border-purple-500/25 text-[11px] text-purple-300/80">
|
||||||
|
💡 开发环境:万能验证码 <b className="text-white">123456</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!phoneValid || !codeValid}
|
||||||
|
loading={submitting}
|
||||||
|
>
|
||||||
|
登录 / 注册
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-white/40 text-center leading-relaxed">
|
||||||
|
未注册手机号将自动创建账号
|
||||||
|
<br />
|
||||||
|
登录即表示同意{" "}
|
||||||
|
<Link href="/terms" className="text-purple-300 hover:underline">
|
||||||
|
用户协议
|
||||||
|
</Link>{" "}
|
||||||
|
和{" "}
|
||||||
|
<Link href="/privacy" className="text-purple-300 hover:underline">
|
||||||
|
隐私协议
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 其他登录方式 · 待开通 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="font-label text-[10px] tracking-widest text-white/35 uppercase mb-3">
|
||||||
|
── 其他登录方式(即将上线)──
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed"
|
||||||
|
aria-label="微信登录(即将上线)"
|
||||||
|
title="微信登录(即将上线)"
|
||||||
|
>
|
||||||
|
<span className="text-base">🔵</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed"
|
||||||
|
aria-label="QQ 登录(即将上线)"
|
||||||
|
title="QQ 登录(即将上线)"
|
||||||
|
>
|
||||||
|
<span className="text-base">🐧</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/login/page.tsx
Normal file
14
src/app/login/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import LoginForm from "./LoginForm";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "登录 · CYBER STAR",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="h-[60vh]" aria-hidden />}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/NavLinks.tsx
Normal file
89
src/components/NavLinks.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ label: "HOME", href: "/" },
|
||||||
|
{ label: "VOTE", href: "/vote" },
|
||||||
|
{ label: "RANKING", href: "/ranking" },
|
||||||
|
{ label: "NEWS", href: "/news" },
|
||||||
|
{ label: "ABOUT", href: "/about" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface NavLinksProps {
|
||||||
|
className?: string;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavLinks({ className, mobile = false }: NavLinksProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-6 px-6 py-2.5 font-display text-[11px] tracking-[0.25em] whitespace-nowrap",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"uppercase transition-colors",
|
||||||
|
active ? "text-purple-300" : "text-white/55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"items-center gap-8 flex-1 font-display text-xs tracking-[0.25em]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"relative py-1 transition-colors uppercase",
|
||||||
|
active
|
||||||
|
? "text-purple-300 glow-text-purple"
|
||||||
|
: "text-white/65 hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-0.5 left-0 right-0 h-px bg-purple-400"
|
||||||
|
style={{ boxShadow: "0 0 8px #a78bfa" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,60 +1,22 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { auth } from "@/lib/auth";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
import NavLinks from "./NavLinks";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
export default async function Navigation() {
|
||||||
{ label: "HOME", href: "/" },
|
const session = await auth();
|
||||||
{ label: "VOTE", href: "/vote" },
|
const user = session?.user;
|
||||||
{ label: "RANKING", href: "/ranking" },
|
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
|
||||||
{ label: "NEWS", href: "/news" },
|
|
||||||
{ label: "ABOUT", href: "/about" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function Navigation() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
|
||||||
if (href === "/") return pathname === "/";
|
|
||||||
return pathname.startsWith(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
|
<header className="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
|
||||||
<nav className="max-w-7xl mx-auto h-16 px-6 sm:px-8 flex items-center gap-8">
|
<nav className="max-w-7xl mx-auto h-16 px-6 sm:px-8 flex items-center gap-8">
|
||||||
<Logo size="md" />
|
<Logo size="md" />
|
||||||
|
|
||||||
{/* Nav links */}
|
<NavLinks className="hidden md:flex" />
|
||||||
<ul className="hidden md:flex items-center gap-8 flex-1 font-display text-xs tracking-[0.25em]">
|
|
||||||
{NAV_ITEMS.map((item) => {
|
|
||||||
const active = isActive(item.href);
|
|
||||||
return (
|
|
||||||
<li key={item.href}>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={`relative py-1 transition-colors uppercase ${
|
|
||||||
active
|
|
||||||
? "text-purple-300 glow-text-purple"
|
|
||||||
: "text-white/65 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
{active && (
|
|
||||||
<span
|
|
||||||
className="absolute -bottom-0.5 left-0 right-0 h-px bg-purple-400"
|
|
||||||
style={{ boxShadow: "0 0 8px #a78bfa" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Right side */}
|
{/* 右侧 */}
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{/* Search button (icon-only on mobile) */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="搜索"
|
aria-label="搜索"
|
||||||
@ -75,36 +37,35 @@ export default function Navigation() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Login button */}
|
{user ? (
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/me"
|
||||||
className="font-display text-[10px] sm:text-xs tracking-[0.2em] uppercase px-4 sm:px-5 h-9 inline-flex items-center justify-center rounded border border-[var(--border-purple)] text-purple-300 hover:bg-purple-500/10 hover:shadow-[0_0_20px_rgba(139,92,246,0.3)] transition-all"
|
className="flex items-center gap-2 px-2.5 h-9 rounded-full bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] transition-colors"
|
||||||
>
|
aria-label="个人中心"
|
||||||
Login / Sign Up
|
>
|
||||||
</Link>
|
<span className="w-7 h-7 rounded-full flex items-center justify-center text-white font-display text-xs bg-grad-purple shadow-[0_0_10px_rgba(139,92,246,0.4)]">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/85 hidden sm:inline max-w-[80px] truncate">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-display text-[10px] sm:text-xs tracking-[0.2em] uppercase px-4 sm:px-5 h-9 inline-flex items-center justify-center rounded border border-[var(--border-purple)] text-purple-300 hover:bg-purple-500/10 hover:shadow-[0_0_20px_rgba(139,92,246,0.3)] transition-all"
|
||||||
|
>
|
||||||
|
Login / Sign Up
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile nav links (horizontal scroll) */}
|
{/* 移动端 nav links */}
|
||||||
<div className="md:hidden border-t border-white/[0.05] overflow-x-auto">
|
<NavLinks
|
||||||
<ul className="flex items-center gap-6 px-6 py-2.5 font-display text-[11px] tracking-[0.25em] whitespace-nowrap">
|
className="md:hidden border-t border-white/[0.05] overflow-x-auto"
|
||||||
{NAV_ITEMS.map((item) => {
|
mobile
|
||||||
const active = isActive(item.href);
|
/>
|
||||||
return (
|
|
||||||
<li key={item.href}>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={`uppercase transition-colors ${
|
|
||||||
active ? "text-purple-300" : "text-white/55"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/lib/auth.ts
Normal file
128
src/lib/auth.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth.js v5 配置
|
||||||
|
*
|
||||||
|
* 支持渠道(按优先级):
|
||||||
|
* 1. 手机号 + 短信 OTP(国内主推)
|
||||||
|
* 2. 微信扫码(待团队配置 appId/secret 后开启)
|
||||||
|
* 3. 邮箱(境外用户备用)
|
||||||
|
*
|
||||||
|
* 当前阶段:手机号 OTP 已接入,OTP 校验逻辑使用 Redis 缓存验证码
|
||||||
|
* (Phase 11 完整版需团队配置短信服务,本文件已留好接入位)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OtpCredentials = z.object({
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.regex(/^1[3-9]\d{9}$/, "请输入有效的中国大陆手机号"),
|
||||||
|
code: z.string().regex(/^\d{6}$/, "请输入 6 位验证码"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authConfig: NextAuthConfig = {
|
||||||
|
adapter: PrismaAdapter(prisma),
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
id: "phone-otp",
|
||||||
|
name: "Phone OTP",
|
||||||
|
credentials: {
|
||||||
|
phone: { label: "手机号", type: "tel" },
|
||||||
|
code: { label: "验证码", type: "text" },
|
||||||
|
},
|
||||||
|
async authorize(raw) {
|
||||||
|
const parsed = OtpCredentials.safeParse(raw);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const { phone, code } = parsed.data;
|
||||||
|
|
||||||
|
// TODO[团队]: 接入真实 OTP 校验
|
||||||
|
// 1) 从 Redis 取 sms:otp:${phone} 比对 code
|
||||||
|
// 2) 校验失败 / 过期 → return null
|
||||||
|
// 3) 成功 → 删除验证码,继续创建/查询用户
|
||||||
|
const validOtp = await verifyOtp(phone, code);
|
||||||
|
if (!validOtp) return null;
|
||||||
|
|
||||||
|
// 查询或创建用户
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { phone },
|
||||||
|
create: {
|
||||||
|
phone,
|
||||||
|
nickname: `粉丝_${phone.slice(-4)}`,
|
||||||
|
loginType: "PHONE",
|
||||||
|
},
|
||||||
|
update: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(user.id),
|
||||||
|
name: user.nickname,
|
||||||
|
image: user.avatar ?? undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// TODO[团队]: 启用微信扫码登录
|
||||||
|
// 需要 WECHAT_APP_ID / WECHAT_APP_SECRET 环境变量。
|
||||||
|
// 实现可参考 https://authjs.dev/guides/configuring-oauth-providers
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// id: "wechat",
|
||||||
|
// name: "WeChat",
|
||||||
|
// type: "oauth",
|
||||||
|
// authorization: { url: "https://open.weixin.qq.com/connect/qrconnect", params: { scope: "snsapi_login" } },
|
||||||
|
// token: "https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||||
|
// userinfo: "https://api.weixin.qq.com/sns/userinfo",
|
||||||
|
// clientId: process.env.WECHAT_APP_ID,
|
||||||
|
// clientSecret: process.env.WECHAT_APP_SECRET,
|
||||||
|
// profile(p) {
|
||||||
|
// return { id: p.openid, name: p.nickname, image: p.headimgurl };
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.uid = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token.uid) {
|
||||||
|
(session.user as { id?: unknown }).id = token.uid;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustHost: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 OTP 验证码(开发态:固定接受 "123456")。
|
||||||
|
* 生产态:从 Redis 读取并比对,校验后删除避免重放。
|
||||||
|
*/
|
||||||
|
async function verifyOtp(phone: string, code: string): Promise<boolean> {
|
||||||
|
if (process.env.NODE_ENV !== "production" && code === "123456") {
|
||||||
|
console.log(`[dev-otp] 手机号 ${phone} 使用万能码 123456 通过`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO[团队]: 接入 Redis
|
||||||
|
// const redis = getRedis();
|
||||||
|
// if (!redis) return false;
|
||||||
|
// const key = `sms:otp:${phone}`;
|
||||||
|
// const stored = await redis.get(key);
|
||||||
|
// if (!stored || stored !== code) return false;
|
||||||
|
// await redis.del(key);
|
||||||
|
// return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,27 +1,31 @@
|
|||||||
import { cookies, headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前登录用户上下文(Phase 11 接入 Auth.js 后由 session 提供)。
|
* 当前登录用户上下文。
|
||||||
*
|
* 由 Auth.js session 提供。
|
||||||
* 当前阶段使用 cookie `cs_user_id` 模拟用户身份,便于开发联调。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
avatar?: string | null;
|
||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
||||||
// TODO[Phase 11]:替换为 await auth() 从 Auth.js session 读取
|
const session = await auth();
|
||||||
const cookieStore = await cookies();
|
if (!session?.user) return null;
|
||||||
const idCookie = cookieStore.get("cs_user_id")?.value;
|
|
||||||
if (!idCookie) return null;
|
// session.user.id 由 auth.ts callbacks 注入
|
||||||
|
const idStr = (session.user as { id?: string }).id;
|
||||||
|
if (!idStr) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
id: BigInt(idCookie),
|
id: BigInt(idStr),
|
||||||
nickname: `dev-user-${idCookie}`,
|
nickname: session.user.name ?? "粉丝",
|
||||||
|
avatar: session.user.image,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user