feat(auth): Auth.js v5 with phone OTP login, send-otp API, login page and user state in nav

This commit is contained in:
iye 2026-05-12 10:03:58 +08:00
parent 175276a085
commit b7fbd5ac53
10 changed files with 734 additions and 143 deletions

View File

@ -14,12 +14,14 @@
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@prisma/client": "^6.19.3",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"next-auth": "5.0.0-beta.31",
"prisma": "^6.19.3",
"react": "19.2.4",
"react-dom": "19.2.4",
@ -35,6 +37,6 @@
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^6.0.3"
}
}

211
pnpm-lock.yaml generated
View File

@ -8,9 +8,12 @@ importers:
.:
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':
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:
specifier: ^2.1.1
version: 2.1.1
@ -26,9 +29,12 @@ importers:
next:
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)
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:
specifier: ^6.19.3
version: 6.19.3(typescript@5.0.2)
version: 6.19.3(typescript@6.0.3)
react:
specifier: 19.2.4
version: 19.2.4
@ -59,7 +65,7 @@ importers:
version: 9.39.4(jiti@2.7.0)
eslint-config-next:
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:
specifier: ^4
version: 4.3.0
@ -67,8 +73,8 @@ importers:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5
version: 5.0.2
specifier: ^6.0.3
version: 6.0.3
packages:
@ -76,6 +82,25 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
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':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@ -619,6 +644,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'}
@ -1726,6 +1754,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-tokens@3.0.0:
resolution: {integrity: sha512-poXEQHPMmTrYZuJgNRll2sbc3kJsSU1m/g1Q93IE6txNj3p6xOOOmdj1G/zCVGawYSPzTkSoWGg1otqbeqKJeg==}
@ -1935,6 +1966,22 @@ packages:
natural-compare@1.4.0:
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:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
@ -1971,6 +2018,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -2080,6 +2130,14 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
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:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -2382,9 +2440,9 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.0.2:
resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==}
engines: {node: '>=12.20'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
unbox-primitive@1.1.0:
@ -2455,6 +2513,23 @@ snapshots:
'@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':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@ -2889,13 +2964,15 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@pkgjs/parseargs@0.11.0':
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:
prisma: 6.19.3(typescript@5.0.2)
typescript: 5.0.2
prisma: 6.19.3(typescript@6.0.3)
typescript: 6.0.3
'@prisma/config@6.19.3':
dependencies:
@ -3027,40 +3104,40 @@ snapshots:
dependencies:
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:
'@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/type-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@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@6.0.3)
'@typescript-eslint/visitor-keys': 8.59.2
eslint: 9.39.4(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.0.2)
typescript: 5.0.2
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- 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:
'@typescript-eslint/scope-manager': 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
debug: 4.4.3
eslint: 9.39.4(jiti@2.7.0)
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- 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:
'@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
debug: 4.4.3
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@ -3069,47 +3146,47 @@ snapshots:
'@typescript-eslint/types': 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:
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:
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.7.0))(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@6.0.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.0.2)
typescript: 5.0.2
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@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:
'@typescript-eslint/project-service': 8.59.2(typescript@5.0.2)
'@typescript-eslint/tsconfig-utils': 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@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.0
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.0.2)
typescript: 5.0.2
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- 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:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
'@typescript-eslint/scope-manager': 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)
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@ -3611,20 +3688,20 @@ snapshots:
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:
'@next/eslint-plugin-next': 16.2.6
eslint: 9.39.4(jiti@2.7.0)
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-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-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))
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:
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-webpack
@ -3650,22 +3727,22 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
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:
- 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:
debug: 3.2.7
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-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))
transitivePeerDependencies:
- 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:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -3676,7 +3753,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.7.0)
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
is-core-module: 2.16.2
is-glob: 4.0.3
@ -3688,7 +3765,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
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:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -4164,6 +4241,8 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.3: {}
js-tokens@3.0.0: {}
js-tokens@4.0.0: {}
@ -4324,6 +4403,12 @@ snapshots:
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):
dependencies:
'@next/env': 16.2.6
@ -4365,6 +4450,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.1.2
oauth4webapi@3.8.6: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -4481,14 +4568,20 @@ snapshots:
picocolors: 1.1.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: {}
prisma@6.19.3(typescript@5.0.2):
prisma@6.19.3(typescript@6.0.3):
dependencies:
'@prisma/config': 6.19.3
'@prisma/engines': 6.19.3
optionalDependencies:
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- magicast
@ -4799,9 +4892,9 @@ snapshots:
dependencies:
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:
typescript: 5.0.2
typescript: 6.0.3
tsconfig-paths@3.15.0:
dependencies:
@ -4856,18 +4949,18 @@ snapshots:
possible-typed-array-names: 1.1.0
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:
'@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/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.0.2)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.0.2)
'@typescript-eslint/utils': 8.59.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@6.0.3)
'@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@6.0.3)
eslint: 9.39.4(jiti@2.7.0)
typescript: 5.0.2
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
typescript@5.0.2: {}
typescript@6.0.3: {}
unbox-primitive@1.1.0:
dependencies:

View File

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View 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));
// 缓存到 Redis5 分钟过期)
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
View 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
View 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>
);
}

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

View File

@ -1,60 +1,22 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { auth } from "@/lib/auth";
import Logo from "./Logo";
import NavLinks from "./NavLinks";
const NAV_ITEMS = [
{ label: "HOME", href: "/" },
{ label: "VOTE", href: "/vote" },
{ label: "RANKING", href: "/ranking" },
{ 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);
};
export default async function Navigation() {
const session = await auth();
const user = session?.user;
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
return (
<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">
<Logo size="md" />
{/* Nav links */}
<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>
<NavLinks className="hidden md:flex" />
{/* Right side */}
{/* 右侧 */}
<div className="ml-auto flex items-center gap-3">
{/* Search button (icon-only on mobile) */}
<button
type="button"
aria-label="搜索"
@ -75,36 +37,35 @@ export default function Navigation() {
</svg>
</button>
{/* Login button */}
<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>
{user ? (
<Link
href="/me"
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="个人中心"
>
<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>
</nav>
{/* Mobile nav links (horizontal scroll) */}
<div className="md:hidden border-t border-white/[0.05] overflow-x-auto">
<ul className="flex items-center gap-6 px-6 py-2.5 font-display text-[11px] tracking-[0.25em] whitespace-nowrap">
{NAV_ITEMS.map((item) => {
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>
{/* 移动端 nav links */}
<NavLinks
className="md:hidden border-t border-white/[0.05] overflow-x-auto"
mobile
/>
</header>
);
}

128
src/lib/auth.ts Normal file
View 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;
}

View File

@ -1,27 +1,31 @@
import { cookies, headers } from "next/headers";
import { headers } from "next/headers";
import { auth } from "./auth";
/**
* Phase 11 Auth.js session
*
* 使 cookie `cs_user_id` 便
*
* Auth.js session
*/
export interface CurrentUser {
id: bigint;
nickname: string;
avatar?: string | null;
isAnonymous: boolean;
}
export async function getCurrentUser(): Promise<CurrentUser | null> {
// TODO[Phase 11]:替换为 await auth() 从 Auth.js session 读取
const cookieStore = await cookies();
const idCookie = cookieStore.get("cs_user_id")?.value;
if (!idCookie) return null;
const session = await auth();
if (!session?.user) return null;
// session.user.id 由 auth.ts callbacks 注入
const idStr = (session.user as { id?: string }).id;
if (!idStr) return null;
try {
return {
id: BigInt(idCookie),
nickname: `dev-user-${idCookie}`,
id: BigInt(idStr),
nickname: session.user.name ?? "粉丝",
avatar: session.user.image,
isAnonymous: false,
};
} catch {