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"
|
||||
},
|
||||
"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
211
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
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 { 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
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 提供)。
|
||||
*
|
||||
* 当前阶段使用 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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user