refactor(auth): migrate social login to next-auth
This commit is contained in:
+1
-2
@@ -15,13 +15,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
"@greatsumini/react-facebook-login": "^3.4.0",
|
|
||||||
"@react-oauth/google": "^0.13.5",
|
|
||||||
"@xstate/react": "^5",
|
"@xstate/react": "^5",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
|
"next-auth": "^4.24.14",
|
||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
Generated
+121
-26
@@ -11,12 +11,6 @@ importers:
|
|||||||
'@fingerprintjs/fingerprintjs':
|
'@fingerprintjs/fingerprintjs':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
'@greatsumini/react-facebook-login':
|
|
||||||
specifier: ^3.4.0
|
|
||||||
version: 3.4.0(react@19.2.4)
|
|
||||||
'@react-oauth/google':
|
|
||||||
specifier: ^0.13.5
|
|
||||||
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
'@xstate/react':
|
'@xstate/react':
|
||||||
specifier: ^5
|
specifier: ^5
|
||||||
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
|
||||||
@@ -32,6 +26,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.2.7
|
specifier: 16.2.7
|
||||||
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
next-auth:
|
||||||
|
specifier: ^4.24.14
|
||||||
|
version: 4.24.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
ofetch:
|
ofetch:
|
||||||
specifier: ^1.5.1
|
specifier: ^1.5.1
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
@@ -167,6 +164,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.29.7':
|
'@babel/template@7.29.7':
|
||||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -282,11 +283,6 @@ packages:
|
|||||||
'@fingerprintjs/fingerprintjs@5.2.0':
|
'@fingerprintjs/fingerprintjs@5.2.0':
|
||||||
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
|
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
|
||||||
|
|
||||||
'@greatsumini/react-facebook-login@3.4.0':
|
|
||||||
resolution: {integrity: sha512-NJYqTOcDghZ6zLppC+LJVLep7Xi3gfx9ssQFRd7EhDTU07lC15SG/eZ3ZyvUPsuPBkbhVl6E/Py0w4M5m7Lh9Q==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -559,15 +555,12 @@ packages:
|
|||||||
'@oxc-project/types@0.133.0':
|
'@oxc-project/types@0.133.0':
|
||||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
'@react-oauth/google@0.13.5':
|
|
||||||
resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16.8.0'
|
|
||||||
react-dom: '>=16.8.0'
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1230,6 +1223,10 @@ packages:
|
|||||||
cookie-es@1.2.3:
|
cookie-es@1.2.3:
|
||||||
resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
|
resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
|
||||||
|
|
||||||
|
cookie@0.7.2:
|
||||||
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1846,6 +1843,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@4.15.9:
|
||||||
|
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||||
|
|
||||||
js-tokens@10.0.0:
|
js-tokens@10.0.0:
|
||||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
@@ -2018,6 +2018,10 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lru-cache@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -2073,6 +2077,20 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
next-auth@4.24.14:
|
||||||
|
resolution: {integrity: sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==}
|
||||||
|
peerDependencies:
|
||||||
|
'@auth/core': 0.34.3
|
||||||
|
next: ^12.2.5 || ^13 || ^14 || ^15 || ^16
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
react: ^17.0.2 || ^18 || ^19
|
||||||
|
react-dom: ^17.0.2 || ^18 || ^19
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@auth/core':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
next@16.2.7:
|
next@16.2.7:
|
||||||
resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==}
|
resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -2112,10 +2130,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
oauth@0.9.15:
|
||||||
|
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
|
||||||
|
|
||||||
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'}
|
||||||
|
|
||||||
|
object-hash@2.2.0:
|
||||||
|
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
object-inspect@1.13.4:
|
object-inspect@1.13.4:
|
||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2151,6 +2176,13 @@ packages:
|
|||||||
ofetch@1.5.1:
|
ofetch@1.5.1:
|
||||||
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
|
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
|
||||||
|
|
||||||
|
oidc-token-hash@5.2.0:
|
||||||
|
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
|
||||||
|
engines: {node: ^10.13.0 || >=12.0.0}
|
||||||
|
|
||||||
|
openid-client@5.7.1:
|
||||||
|
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2239,10 +2271,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact-render-to-string@5.2.6:
|
||||||
|
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.29.2:
|
||||||
|
resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==}
|
||||||
|
|
||||||
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'}
|
||||||
|
|
||||||
|
pretty-format@3.8.0:
|
||||||
|
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2669,6 +2712,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
uuid@8.3.2:
|
||||||
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite@8.0.16:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2820,6 +2868,9 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yallist@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2942,6 +2993,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/template@7.29.7':
|
'@babel/template@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -3061,10 +3114,6 @@ snapshots:
|
|||||||
|
|
||||||
'@fingerprintjs/fingerprintjs@5.2.0': {}
|
'@fingerprintjs/fingerprintjs@5.2.0': {}
|
||||||
|
|
||||||
'@greatsumini/react-facebook-login@3.4.0(react@19.2.4)':
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.4
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@@ -3250,12 +3299,9 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.133.0': {}
|
'@oxc-project/types@0.133.0': {}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@polka/url@1.0.0-next.29': {}
|
||||||
dependencies:
|
|
||||||
react: 19.2.4
|
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.3':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -3880,6 +3926,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@1.2.3: {}
|
cookie-es@1.2.3: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -4645,6 +4693,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@4.15.9: {}
|
||||||
|
|
||||||
js-tokens@10.0.0: {}
|
js-tokens@10.0.0: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@@ -4802,6 +4852,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lru-cache@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
yallist: 4.0.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -4847,6 +4901,21 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
next-auth@4.24.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@panva/hkdf': 1.2.1
|
||||||
|
cookie: 0.7.2
|
||||||
|
jose: 4.15.9
|
||||||
|
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
oauth: 0.9.15
|
||||||
|
openid-client: 5.7.1
|
||||||
|
preact: 10.29.2
|
||||||
|
preact-render-to-string: 5.2.6(preact@10.29.2)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
uuid: 8.3.2
|
||||||
|
|
||||||
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.7
|
'@next/env': 16.2.7
|
||||||
@@ -4886,8 +4955,12 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
|
oauth@0.9.15: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
|
object-hash@2.2.0: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
@@ -4936,6 +5009,15 @@ snapshots:
|
|||||||
node-fetch-native: 1.6.7
|
node-fetch-native: 1.6.7
|
||||||
ufo: 1.6.4
|
ufo: 1.6.4
|
||||||
|
|
||||||
|
oidc-token-hash@5.2.0: {}
|
||||||
|
|
||||||
|
openid-client@5.7.1:
|
||||||
|
dependencies:
|
||||||
|
jose: 4.15.9
|
||||||
|
lru-cache: 6.0.0
|
||||||
|
object-hash: 2.2.0
|
||||||
|
oidc-token-hash: 5.2.0
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -5019,8 +5101,17 @@ 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@5.2.6(preact@10.29.2):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.29.2
|
||||||
|
pretty-format: 3.8.0
|
||||||
|
|
||||||
|
preact@10.29.2: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
|
pretty-format@3.8.0: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -5506,6 +5597,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0):
|
vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -5632,6 +5725,8 @@ snapshots:
|
|||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yallist@4.0.0: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* NextAuth App Router 入口
|
||||||
|
*
|
||||||
|
* 路径:/api/auth/[...nextauth]
|
||||||
|
* 自动处理 NextAuth 所有内置路由:
|
||||||
|
* /api/auth/signin/[provider]
|
||||||
|
* /api/auth/callback/[provider]
|
||||||
|
* /api/auth/signout
|
||||||
|
* /api/auth/session
|
||||||
|
* /api/auth/csrf
|
||||||
|
* /api/auth/providers
|
||||||
|
*/
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
|
||||||
|
import { authOptions } from "@/lib/auth/config";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -6,54 +6,54 @@
|
|||||||
*
|
*
|
||||||
* 行为:
|
* 行为:
|
||||||
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
||||||
* - 真实 Facebook 登录在「Continue with Facebook」按钮中触发
|
* - 社交登录直接调 `nextauth.facebookLogin()` / `nextauth.googleLogin()`(NextAuth 流程)
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
import { facebookLogin, googleLogin } from "@/lib/auth/nextauth";
|
||||||
|
|
||||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||||
import { AuthDivider } from "./auth-divider";
|
import { AuthDivider } from "./auth-divider";
|
||||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||||
import { AuthErrorMessage } from "./auth-error-message";
|
import { AuthErrorMessage } from "./auth-error-message";
|
||||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||||
import { GoogleSignInButton } from "./google-sign-in-button";
|
|
||||||
import { AuthLegalText } from "./auth-legal-text";
|
import { AuthLegalText } from "./auth-legal-text";
|
||||||
import styles from "./auth-facebook-panel.module.css";
|
import styles from "./auth-facebook-panel.module.css";
|
||||||
|
|
||||||
export interface AuthFacebookPanelProps {
|
export interface AuthFacebookPanelProps {
|
||||||
onSwitchToEmail: () => void;
|
onSwitchToEmail: () => void;
|
||||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthFacebookPanel({
|
export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
|
||||||
onSwitchToEmail,
|
|
||||||
onGoogleSuccess,
|
|
||||||
}: AuthFacebookPanelProps) {
|
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const dispatch = useAuthDispatch();
|
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<null | "facebook" | "google">(null);
|
||||||
|
|
||||||
|
const handleFacebook = async () => {
|
||||||
|
setBusy("facebook");
|
||||||
|
const r = await facebookLogin();
|
||||||
|
setBusy(null);
|
||||||
|
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogle = async () => {
|
||||||
|
setBusy("google");
|
||||||
|
const r = await googleLogin();
|
||||||
|
setBusy(null);
|
||||||
|
if (!r.success) console.error("[auth-facebook-panel]", r.error);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<AuthErrorMessage message={state.errorMessage} />
|
<AuthErrorMessage message={state.errorMessage} />
|
||||||
<AuthSocialButtons
|
<AuthSocialButtons
|
||||||
loadingProvider={state.isLoading ? "facebook" : null}
|
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||||
onFacebook={() => dispatch({ type: "AuthFacebookLoginSubmitted" })}
|
onFacebook={handleFacebook}
|
||||||
onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
|
onGoogle={handleGoogle}
|
||||||
googleSlot={
|
|
||||||
<GoogleSignInButton
|
|
||||||
onSuccess={(s) =>
|
|
||||||
onGoogleSuccess(s.idToken, s.email)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<AuthDivider label="or" />
|
<AuthDivider label="or" />
|
||||||
<AuthPrimaryButton
|
<AuthPrimaryButton onClick={onSwitchToEmail} variant="primary">
|
||||||
onClick={onSwitchToEmail}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
Continue with Email
|
Continue with Email
|
||||||
</AuthPrimaryButton>
|
</AuthPrimaryButton>
|
||||||
<button
|
<button
|
||||||
@@ -69,21 +69,21 @@ export function AuthFacebookPanel({
|
|||||||
onClose={() => setShowOptions(false)}
|
onClose={() => setShowOptions(false)}
|
||||||
onFacebook={() => {
|
onFacebook={() => {
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
dispatch({ type: "AuthFacebookLoginSubmitted" });
|
void handleFacebook();
|
||||||
}}
|
}}
|
||||||
onGoogle={() => {
|
onGoogle={() => {
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
dispatch({ type: "AuthGoogleLoginSubmitted" });
|
void handleGoogle();
|
||||||
}}
|
}}
|
||||||
onApple={() => {
|
onApple={() => {
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
dispatch({ type: "AuthAppleLoginSubmitted" });
|
console.warn("Apple login not implemented");
|
||||||
}}
|
}}
|
||||||
onEmail={() => {
|
onEmail={() => {
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
onSwitchToEmail();
|
onSwitchToEmail();
|
||||||
}}
|
}}
|
||||||
loadingProvider={state.isLoading ? "facebook" : null}
|
loadingProvider={busy ?? (state.isLoading ? "facebook" : null)}
|
||||||
showApple={false}
|
showApple={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import { AuthFacebookPanel } from "./auth-facebook-panel";
|
|||||||
import { AuthEmailPanel } from "./auth-email-panel";
|
import { AuthEmailPanel } from "./auth-email-panel";
|
||||||
import styles from "./auth-panel.module.css";
|
import styles from "./auth-panel.module.css";
|
||||||
|
|
||||||
export interface AuthPanelProps {
|
export function AuthPanel() {
|
||||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
@@ -26,7 +22,6 @@ export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
|||||||
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
||||||
// 实际上本组件直接渲染对应 panel
|
// 实际上本组件直接渲染对应 panel
|
||||||
}}
|
}}
|
||||||
onGoogleSuccess={onGoogleSuccess}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AuthEmailPanel onBack={() => undefined} />
|
<AuthEmailPanel onBack={() => undefined} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
import { useUserDispatch } from "@/stores/user/user-context";
|
import { useUserDispatch } from "@/stores/user/user-context";
|
||||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
import { useAuthGate } from "@/lib/auth/nextauth";
|
||||||
import { ROUTES } from "@/lib/routes";
|
import { ROUTES } from "@/lib/routes";
|
||||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
||||||
@@ -42,7 +42,7 @@ export function AuthScreen({
|
|||||||
if (isAuthed) router.replace(ROUTES.chat);
|
if (isAuthed) router.replace(ROUTES.chat);
|
||||||
}, [isAuthed, router]);
|
}, [isAuthed, router]);
|
||||||
|
|
||||||
// 登录成功 → 通知 Chat + User + 跳转
|
// 邮箱登录成功 → 通知 Chat + User + 跳转
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.isSuccess && !wasSuccess.current) {
|
if (state.isSuccess && !wasSuccess.current) {
|
||||||
wasSuccess.current = true;
|
wasSuccess.current = true;
|
||||||
@@ -63,11 +63,7 @@ export function AuthScreen({
|
|||||||
padding: "var(--spacing-lg)",
|
padding: "var(--spacing-lg)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AuthPanel
|
<AuthPanel />
|
||||||
onGoogleSuccess={(idToken, email) => {
|
|
||||||
authDispatch({ type: "AuthWebGoogleLoginSuccess", idToken, email });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
/**
|
|
||||||
* Google 登录按钮(GIS 渲染)
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/auth/widgets/google_sign_in_web.dart
|
|
||||||
*
|
|
||||||
* 通过 `google.accounts.id.renderButton` 把 GIS 官方按钮渲染到指定 div。
|
|
||||||
* 登录成功由 `setOnGoogleSignInSuccess` 设置的回调处理。
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
renderGoogleButton,
|
|
||||||
setOnGoogleSignInSuccess,
|
|
||||||
type GoogleSignInSuccess,
|
|
||||||
} from "@/integrations/google-identity";
|
|
||||||
import styles from "./google-sign-in-button.module.css";
|
|
||||||
|
|
||||||
export interface GoogleSignInButtonProps {
|
|
||||||
onSuccess: (success: GoogleSignInSuccess) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoogleSignInButton({ onSuccess }: GoogleSignInButtonProps) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOnGoogleSignInSuccess(onSuccess);
|
|
||||||
return () => setOnGoogleSignInSuccess(null);
|
|
||||||
}, [onSuccess]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
let cancelled = false;
|
|
||||||
// GIS 异步加载,轮询直到可用
|
|
||||||
const tryRender = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (window.google?.accounts?.id) {
|
|
||||||
renderGoogleButton(ref.current!, { theme: "outline", size: "large" });
|
|
||||||
} else {
|
|
||||||
setTimeout(tryRender, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tryRender();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <div ref={ref} className={styles.slot} />;
|
|
||||||
}
|
|
||||||
@@ -17,5 +17,4 @@ export * from "./auth-text-field";
|
|||||||
export * from "./auth-validators";
|
export * from "./auth-validators";
|
||||||
export * from "./email-login-form";
|
export * from "./email-login-form";
|
||||||
export * from "./email-register-form";
|
export * from "./email-register-form";
|
||||||
export * from "./google-sign-in-button";
|
|
||||||
export * from "./__tests__/auth-validators.test";
|
export * from "./__tests__/auth-validators.test";
|
||||||
|
|||||||
+6
-13
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import Script from "next/script";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import { RootProviders } from "@/providers/root-providers";
|
import { RootProviders } from "@/providers/root-providers";
|
||||||
@@ -61,24 +61,17 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
// suppressHydrationWarning:next/font + Tailwind v4 偶发类名差异;详见
|
// suppressHydrationWarning:next/font + Tailwind v4 偶发类名差异;详见
|
||||||
// `src/lib/auth/use-auth-gate.tsx` 的"水合闪屏"说明。
|
// `src/lib/auth/nextauth.ts` 的"水合闪屏"说明。
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
{/* Facebook SDK JS(auth + splash 入口会用到 FB.login) */}
|
{/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
|
||||||
<Script
|
<SessionProvider>
|
||||||
src="https://connect.facebook.net/en_US/sdk.js"
|
<RootProviders>{children}</RootProviders>
|
||||||
strategy="afterInteractive"
|
</SessionProvider>
|
||||||
/>
|
|
||||||
{/* Google Identity Services(auth 入口会用到 google.accounts.id) */}
|
|
||||||
<Script
|
|
||||||
src="https://accounts.google.com/gsi/client"
|
|
||||||
strategy="afterInteractive"
|
|
||||||
/>
|
|
||||||
<RootProviders>{children}</RootProviders>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,16 +6,12 @@
|
|||||||
* `Row(children: [Skip 文本, SizedBox 26, Facebook 登录按钮])`
|
* `Row(children: [Skip 文本, SizedBox 26, Facebook 登录按钮])`
|
||||||
*
|
*
|
||||||
* 本组件是 splash 鉴权流程的**自治单元**:
|
* 本组件是 splash 鉴权流程的**自治单元**:
|
||||||
* - 直接消费 `useAuthGate` / `useAuthState` / `useAuthDispatch`
|
* - 消费 `useAuthGate`(内部用 useSession 监听 NextAuth session)
|
||||||
* - 直接消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录流程)
|
||||||
|
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
||||||
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
||||||
* - Skip 用 `<Link>` 保留 SPA 体验
|
* - Skip 用 `<Link>` 保留 SPA 体验
|
||||||
*
|
* - 社交登录按钮直接调 `nextauth.facebookLogin()`(NextAuth 流程)
|
||||||
* 设计要点:
|
|
||||||
* - 仅两个元素:Skip 文本 + Facebook 登录按钮
|
|
||||||
* - 居中布局 (MainAxisAlignment.center)
|
|
||||||
* - Facebook 按钮:带渐变(primaryGradient)+ 圆角 24 + 高度 48
|
|
||||||
* - 加载中:按钮内显示 spinner
|
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -24,7 +20,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
import { useUserDispatch } from "@/stores/user/user-context";
|
import { useUserDispatch } from "@/stores/user/user-context";
|
||||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
import { facebookLogin, useAuthGate } from "@/lib/auth/nextauth";
|
||||||
import { ROUTES } from "@/lib/routes";
|
import { ROUTES } from "@/lib/routes";
|
||||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||||
import styles from "./splash-button.module.css";
|
import styles from "./splash-button.module.css";
|
||||||
@@ -52,7 +48,7 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
|||||||
if (isAuthed) router.replace(ROUTES.chat);
|
if (isAuthed) router.replace(ROUTES.chat);
|
||||||
}, [isAuthed, router]);
|
}, [isAuthed, router]);
|
||||||
|
|
||||||
// 登录成功跳 /chat + 通知 chat/user 初始化
|
// 邮箱登录成功跳 /chat + 通知 chat/user 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.isSuccess && !wasSuccess.current) {
|
if (state.isSuccess && !wasSuccess.current) {
|
||||||
wasSuccess.current = true;
|
wasSuccess.current = true;
|
||||||
@@ -62,6 +58,13 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
|||||||
}
|
}
|
||||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||||
|
|
||||||
|
const handleFacebookLogin = async () => {
|
||||||
|
const r = await facebookLogin();
|
||||||
|
if (!r.success) {
|
||||||
|
console.error("[splash-button] Facebook login failed", r.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{/* Skip 文本链接(next/link 渲染为 <a>,保留 SPA 体验) */}
|
{/* Skip 文本链接(next/link 渲染为 <a>,保留 SPA 体验) */}
|
||||||
@@ -77,12 +80,10 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
|||||||
|
|
||||||
<div className={styles.separator} aria-hidden="true" />
|
<div className={styles.separator} aria-hidden="true" />
|
||||||
|
|
||||||
{/* Facebook 登录按钮(带渐变) */}
|
{/* 社交登录按钮(NextAuth Facebook OAuth) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleFacebookLogin}
|
||||||
authDispatch({ type: "AuthFacebookLoginSubmitted" });
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={styles.facebookButton}
|
className={styles.facebookButton}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 第三方登录 Bridge 组件。
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/utils/google_auth_initializer.dart + lib/utils/facebook_auth_initializer.dart
|
|
||||||
*
|
|
||||||
* 设计:
|
|
||||||
* - 不渲染任何 UI(`children` 透传)。
|
|
||||||
* - 通过 `@react-oauth/google` 的 `GoogleOAuthProvider` 加载的 GIS 脚本,
|
|
||||||
* 调用 `window.google.accounts.id.initialize` + `prompt` 实现 Google 登录弹窗;
|
|
||||||
* callback 收到 id_token 后解析 JWT payload 拿到 email/displayName/photoUrl。
|
|
||||||
* - 通过 `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init` +
|
|
||||||
* `FacebookLoginClient.login` 实现 Facebook 登录弹窗。
|
|
||||||
* - 监听 `auth_event_bridge` 派发的 `cozsweet:auth:*:start` 事件,触发对应
|
|
||||||
* SDK 流程;拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。
|
|
||||||
* - 单 popup 语义:v1 一次只追踪一个待处理 Google / Facebook 请求,多个并发
|
|
||||||
* 调用会被覆盖。后续如需并发支持,需把 ref 改为 `Map<requestId, resolver>`。
|
|
||||||
*
|
|
||||||
* 部署:本步骤**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载:
|
|
||||||
*
|
|
||||||
* import { GoogleOAuthProvider } from "@react-oauth/google";
|
|
||||||
* import { AuthPlatformBridge } from "@/data/services/auth";
|
|
||||||
*
|
|
||||||
* <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
|
|
||||||
* <AuthPlatformBridge platform={getAuthPlatform({...})}>
|
|
||||||
* {children}
|
|
||||||
* </AuthPlatformBridge>
|
|
||||||
* </GoogleOAuthProvider>
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useGoogleOAuth } from "@react-oauth/google";
|
|
||||||
import { FacebookLoginClient, LoginStatus } from "@greatsumini/react-facebook-login";
|
|
||||||
import { Result, toError, type Result as ResultT } from "@/utils/result";
|
|
||||||
import type {
|
|
||||||
IAuthPlatform,
|
|
||||||
IAuthPlatformOptions,
|
|
||||||
GoogleCredential,
|
|
||||||
FacebookLoginResult,
|
|
||||||
} from "./auth_platform";
|
|
||||||
import { AUTH_EVENTS, onAuthEvent } from "./auth_event_bridge";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge 要求 platform 是带 `opts` + `resolveGoogle` + `resolveFacebook` 的
|
|
||||||
* WebAuthPlatform。结构化类型便于 mock / 子类扩展。
|
|
||||||
*/
|
|
||||||
export interface AuthPlatformBridgeProps {
|
|
||||||
platform: IAuthPlatform & {
|
|
||||||
readonly opts: IAuthPlatformOptions;
|
|
||||||
resolveGoogle(
|
|
||||||
requestId: string,
|
|
||||||
result: ResultT<GoogleCredential>,
|
|
||||||
): void;
|
|
||||||
resolveFacebook(
|
|
||||||
requestId: string,
|
|
||||||
result: ResultT<FacebookLoginResult>,
|
|
||||||
): void;
|
|
||||||
};
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthPlatformBridge({
|
|
||||||
platform,
|
|
||||||
children,
|
|
||||||
}: AuthPlatformBridgeProps) {
|
|
||||||
const platformRef = useRef(platform);
|
|
||||||
// 在 effect 中更新 ref(react-hooks/refs 规则禁止 render 期更新 ref.current)。
|
|
||||||
// 这样后续 effect / callback 闭包总能拿到最新的 platform 引用。
|
|
||||||
useEffect(() => {
|
|
||||||
platformRef.current = platform;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ======== Google GIS ========
|
|
||||||
|
|
||||||
const { scriptLoadedSuccessfully } = useGoogleOAuth();
|
|
||||||
const pendingGoogleRef = useRef<{ requestId: string } | null>(null);
|
|
||||||
|
|
||||||
// 初始化 GIS callback(一次性;GIS 全局只允许一个 id initialize 回调)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scriptLoadedSuccessfully) return;
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const gis = window.google?.accounts?.id;
|
|
||||||
if (!gis) return;
|
|
||||||
|
|
||||||
gis.initialize({
|
|
||||||
client_id: platformRef.current.opts.googleClientId,
|
|
||||||
callback: (response) => {
|
|
||||||
const pending = pendingGoogleRef.current;
|
|
||||||
pendingGoogleRef.current = null;
|
|
||||||
if (!pending) return;
|
|
||||||
if (response.credential) {
|
|
||||||
const decoded = decodeJwtPayload(response.credential);
|
|
||||||
platformRef.current.resolveGoogle(
|
|
||||||
pending.requestId,
|
|
||||||
Result.ok<GoogleCredential>({
|
|
||||||
idToken: response.credential,
|
|
||||||
email: decoded?.email ?? null,
|
|
||||||
displayName: decoded?.name ?? null,
|
|
||||||
photoUrl: decoded?.picture ?? null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// 全局成功回调
|
|
||||||
platformRef.current.opts.onGoogleSignInSuccess?.(
|
|
||||||
response.credential,
|
|
||||||
decoded?.email ?? null,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
platformRef.current.resolveGoogle(
|
|
||||||
pending.requestId,
|
|
||||||
Result.err<GoogleCredential>(
|
|
||||||
new Error("Google sign-in failed: no credential"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [scriptLoadedSuccessfully]);
|
|
||||||
|
|
||||||
// 监听 googleStart 事件 → 触发 prompt
|
|
||||||
useEffect(() => {
|
|
||||||
return onAuthEvent(AUTH_EVENTS.googleStart, (detail) => {
|
|
||||||
pendingGoogleRef.current = { requestId: detail.requestId };
|
|
||||||
const gis =
|
|
||||||
typeof window !== "undefined" ? window.google?.accounts?.id : undefined;
|
|
||||||
if (gis) {
|
|
||||||
gis.prompt();
|
|
||||||
} else {
|
|
||||||
// GIS 未加载完成:立即 reject
|
|
||||||
pendingGoogleRef.current = null;
|
|
||||||
platformRef.current.resolveGoogle(
|
|
||||||
detail.requestId,
|
|
||||||
Result.err<GoogleCredential>(
|
|
||||||
new Error("Google Identity Services script not loaded"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ======== Facebook SDK ========
|
|
||||||
|
|
||||||
const pendingFacebookRef = useRef<{ requestId: string } | null>(null);
|
|
||||||
|
|
||||||
// 初始化 Facebook SDK(一次性)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
// 版本联合类型截止 v21.0,但底层 FB JS SDK 支持 v25.0;用类型断言绕过。
|
|
||||||
FacebookLoginClient.init({
|
|
||||||
appId: platformRef.current.opts.facebookAppId,
|
|
||||||
version: (platformRef.current.opts.facebookApiVersion ??
|
|
||||||
"v25.0") as "v21.0",
|
|
||||||
cookie: true,
|
|
||||||
xfbml: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[auth] FacebookLoginClient.init failed", e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听 facebookStart 事件 → 触发 login
|
|
||||||
useEffect(() => {
|
|
||||||
return onAuthEvent(AUTH_EVENTS.facebookStart, (detail) => {
|
|
||||||
pendingFacebookRef.current = { requestId: detail.requestId };
|
|
||||||
try {
|
|
||||||
FacebookLoginClient.login(
|
|
||||||
(response) => {
|
|
||||||
const pending = pendingFacebookRef.current;
|
|
||||||
pendingFacebookRef.current = null;
|
|
||||||
if (!pending) return;
|
|
||||||
if (
|
|
||||||
response.status === LoginStatus.Connected &&
|
|
||||||
response.authResponse
|
|
||||||
) {
|
|
||||||
const expiresIn = Number(response.authResponse.expiresIn);
|
|
||||||
const expiresAt = Number.isFinite(expiresIn)
|
|
||||||
? Date.now() + expiresIn * 1000
|
|
||||||
: null;
|
|
||||||
platformRef.current.resolveFacebook(
|
|
||||||
pending.requestId,
|
|
||||||
Result.ok<FacebookLoginResult>({
|
|
||||||
status: "success",
|
|
||||||
accessToken: response.authResponse.accessToken,
|
|
||||||
userId: response.authResponse.userID,
|
|
||||||
expiresAt,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// NotAuthorized / Unknown / loginCancelled / facebookNotLoaded
|
|
||||||
platformRef.current.resolveFacebook(
|
|
||||||
pending.requestId,
|
|
||||||
Result.ok<FacebookLoginResult>({
|
|
||||||
status: "cancelled",
|
|
||||||
accessToken: "",
|
|
||||||
userId: "",
|
|
||||||
expiresAt: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "public_profile,email" },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const pending = pendingFacebookRef.current;
|
|
||||||
pendingFacebookRef.current = null;
|
|
||||||
if (pending) {
|
|
||||||
platformRef.current.resolveFacebook(
|
|
||||||
pending.requestId,
|
|
||||||
Result.err<FacebookLoginResult>(toError(e)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======== Helpers ========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解码 JWT payload 段。不做签名校验——签名由后端校验。
|
|
||||||
* 返回 null 表示解码失败。
|
|
||||||
*/
|
|
||||||
function decodeJwtPayload(
|
|
||||||
idToken: string,
|
|
||||||
): { email?: string; name?: string; picture?: string } | null {
|
|
||||||
try {
|
|
||||||
const parts = idToken.split(".");
|
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
||||||
const decoded = atob(padded);
|
|
||||||
return JSON.parse(decoded) as {
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
picture?: string;
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跨 plain-TS / React 边界的 DOM 事件通道。
|
|
||||||
*
|
|
||||||
* 用途:
|
|
||||||
* - `WebAuthPlatform`(plain TS class,挂在 `getAuthPlatform()` 单例上)dispatch
|
|
||||||
* `cozsweet:auth:*:start` 事件,并等待 `AuthPlatformBridge`("use client" 组件)
|
|
||||||
* 调回 `platform.resolveGoogle()` / `platform.resolveFacebook()` 完成 Promise。
|
|
||||||
* - 这样既满足"接口保持 plain TS、可以被非 React 上下文消费"的需求,又让
|
|
||||||
* `@react-oauth/google` 的 `GoogleOAuthProvider`(脚本加载)和
|
|
||||||
* `window.google.accounts.id.*` GIS API 都在 React 侧执行。
|
|
||||||
*
|
|
||||||
* 本文件不依赖 React,可以被 `WebAuthPlatform`(无 React 引用)安全 import。
|
|
||||||
*/
|
|
||||||
export const AUTH_EVENTS = {
|
|
||||||
googleStart: "cozsweet:auth:google:start",
|
|
||||||
facebookStart: "cozsweet:auth:facebook:start",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AuthEventName = (typeof AUTH_EVENTS)[keyof typeof AUTH_EVENTS];
|
|
||||||
|
|
||||||
/** Payload attached to *:start events. */
|
|
||||||
export interface AuthStartDetail {
|
|
||||||
readonly requestId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchAuthEvent(
|
|
||||||
name: AuthEventName,
|
|
||||||
detail: AuthStartDetail,
|
|
||||||
): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.dispatchEvent(new CustomEvent<AuthStartDetail>(name, { detail }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onAuthEvent(
|
|
||||||
name: AuthEventName,
|
|
||||||
handler: (detail: AuthStartDetail) => void,
|
|
||||||
): () => void {
|
|
||||||
if (typeof window === "undefined") return () => {};
|
|
||||||
const listener = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent<AuthStartDetail>).detail;
|
|
||||||
handler(detail);
|
|
||||||
};
|
|
||||||
window.addEventListener(name, listener);
|
|
||||||
return () => window.removeEventListener(name, listener);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 第三方登录平台抽象接口 + 工厂 + 单例
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/data/services/auth/auth_platform.dart
|
|
||||||
*
|
|
||||||
* 设计:
|
|
||||||
* - `IAuthPlatform` 是 plain TypeScript 接口(不依赖 React)。
|
|
||||||
* - 真机实现 `WebAuthPlatform` 通过 DOM 事件与 `AuthPlatformBridge`("use client"
|
|
||||||
* 组件)通信,由 Bridge 持有 React SDK 钩子。
|
|
||||||
* - Mock 实现 `MockAuthPlatform` 跳过 SDK,直接返回固定 token。
|
|
||||||
* - 工厂根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 环境变量二选一(对齐 Dart `kDebugMode`)。
|
|
||||||
*
|
|
||||||
* 调用方契约:
|
|
||||||
* - 平台**只产 token**,不调 `authRepository` / `authApi`。
|
|
||||||
* - 拿到 `Result<GoogleCredential>` / `Result<FacebookLoginResult>` 后,
|
|
||||||
* 由调用方自行 `authRepository.googleLogin({ idToken })` 完成登录。
|
|
||||||
*/
|
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
|
||||||
import type { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
|
||||||
import { WebAuthPlatform } from "./web_auth_platform";
|
|
||||||
import { MockAuthPlatform } from "./mock_auth_platform";
|
|
||||||
|
|
||||||
/** 归一化的 Google 凭证(id_token + 解析自 JWT payload 的展示字段)。 */
|
|
||||||
export interface GoogleCredential {
|
|
||||||
readonly idToken: string;
|
|
||||||
readonly email: string | null;
|
|
||||||
readonly displayName: string | null;
|
|
||||||
readonly photoUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 归一化的 Facebook 登录结果。 */
|
|
||||||
export interface FacebookLoginResult {
|
|
||||||
readonly status: "success" | "cancelled" | "error";
|
|
||||||
readonly accessToken: string;
|
|
||||||
readonly userId: string;
|
|
||||||
/** epoch ms;null 表示后端未返回 expiresIn。 */
|
|
||||||
readonly expiresAt: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAuthPlatform {
|
|
||||||
/** Google 登录。Web 平台通过 GIS 弹窗流程。 */
|
|
||||||
googleSignIn(): Promise<ResultT<GoogleCredential>>;
|
|
||||||
/** 是否支持 Google "authenticate" 流程。Web 平台恒为 false。 */
|
|
||||||
supportsGoogleAuthenticate(): boolean;
|
|
||||||
/** Facebook 登录。 */
|
|
||||||
facebookSignIn(): Promise<ResultT<FacebookLoginResult>>;
|
|
||||||
/** 拉取 Facebook 用户数据(id / name / email / pictureUrl)。 */
|
|
||||||
getFacebookUserData(): Promise<ResultT<FacebookUserData>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAuthPlatformOptions {
|
|
||||||
readonly googleClientId: string;
|
|
||||||
readonly facebookAppId: string;
|
|
||||||
/** Facebook Graph API 版本,默认 "v25.0"(对齐 Flutter 端)。 */
|
|
||||||
readonly facebookApiVersion?: string;
|
|
||||||
/** 全局 Google 登录成功回调(对齐 Dart `onGoogleSignInSuccess`)。 */
|
|
||||||
readonly onGoogleSignInSuccess?: (
|
|
||||||
idToken: string,
|
|
||||||
email: string | null,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthPlatformFactory {
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 二选一返回平台实现。
|
|
||||||
* 对齐 Dart `AuthPlatformFactory.create()` 的 `kDebugMode` 分支。
|
|
||||||
*/
|
|
||||||
static create(opts: IAuthPlatformOptions): IAuthPlatform {
|
|
||||||
if (process.env.NEXT_PUBLIC_USE_MOCK_AUTH === "true") {
|
|
||||||
return new MockAuthPlatform();
|
|
||||||
}
|
|
||||||
return new WebAuthPlatform(opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 顶层单例(懒加载)。 */
|
|
||||||
let _instance: IAuthPlatform | null = null;
|
|
||||||
export function getAuthPlatform(opts: IAuthPlatformOptions): IAuthPlatform {
|
|
||||||
if (!_instance) _instance = AuthPlatformFactory.create(opts);
|
|
||||||
return _instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal 测试用:重置单例。 */
|
|
||||||
export function _resetAuthPlatformForTests(): void {
|
|
||||||
_instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// re-export 让 barrelsby 抓得到
|
|
||||||
export { Result };
|
|
||||||
Vendored
-50
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* 全局类型声明:Google Identity Services (`gis`) 添加的 `window.google` 全局对象。
|
|
||||||
*
|
|
||||||
* 由 `<GoogleOAuthProvider>` / `useGoogleOAuth()` 注入的 GIS 脚本会向 `window`
|
|
||||||
* 挂载 `google.accounts.id` / `google.accounts.oauth2` 等命名空间。本文件让
|
|
||||||
* TypeScript 知道这些属性的存在,避免在 Bridge 组件中用 `as unknown as ...`
|
|
||||||
* 强转。
|
|
||||||
*
|
|
||||||
* 仅声明我们用到的子集;未列出的 GIS 属性走 `unknown`。
|
|
||||||
*/
|
|
||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
google?: {
|
|
||||||
accounts?: {
|
|
||||||
id?: {
|
|
||||||
initialize(config: {
|
|
||||||
client_id: string;
|
|
||||||
callback: (response: { credential?: string }) => void;
|
|
||||||
auto_select?: boolean;
|
|
||||||
cancel_on_tap_outside?: boolean;
|
|
||||||
itp_support?: boolean;
|
|
||||||
login_hint?: string;
|
|
||||||
hosted_domain?: string;
|
|
||||||
use_fedcm_for_prompt?: boolean;
|
|
||||||
nonce?: string;
|
|
||||||
state_cookie_domain?: string;
|
|
||||||
prompt_parent_id?: string;
|
|
||||||
}): void;
|
|
||||||
prompt(): void;
|
|
||||||
renderButton(
|
|
||||||
parent: HTMLElement,
|
|
||||||
options: {
|
|
||||||
type?: "standard" | "icon";
|
|
||||||
theme?: "outline" | "filled_blue" | "filled_black";
|
|
||||||
size?: "large" | "medium" | "small";
|
|
||||||
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
|
||||||
shape?: "rectangular" | "pill" | "circle" | "square";
|
|
||||||
logo_alignment?: "left" | "center";
|
|
||||||
width?: string | number;
|
|
||||||
},
|
|
||||||
): void;
|
|
||||||
cancel(): void;
|
|
||||||
};
|
|
||||||
oauth2?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file Automatically generated by barrelsby.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./auth_bridge_component";
|
|
||||||
export * from "./auth_event_bridge";
|
|
||||||
export * from "./auth_platform";
|
|
||||||
export * from "./global";
|
|
||||||
export * from "./initialize_facebook_auth";
|
|
||||||
export * from "./initialize_google_auth";
|
|
||||||
export * from "./mock_auth_platform";
|
|
||||||
export * from "./web_auth_platform";
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Facebook SDK 预热辅助。
|
|
||||||
*
|
|
||||||
* 原始 Dart: `lib/utils/facebook_auth_initializer.dart` 的 `initializeFacebookAuth()`
|
|
||||||
*
|
|
||||||
* 行为:
|
|
||||||
* - 对齐 Dart `FacebookAuth.i.webAndDesktopInitialize(appId, cookie: true,
|
|
||||||
* xfbml: true, version: 'v25.0')` 的参数语义。
|
|
||||||
* - 真实 SDK 初始化(注入 `<div id="facebook-jssdk">` + 调用 `FB.init`)由
|
|
||||||
* `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init()` 完成,
|
|
||||||
* 见 `auth_bridge_component.tsx`。本函数仅做存在性校验 + console.warn。
|
|
||||||
*/
|
|
||||||
export function initializeFacebookAuth(
|
|
||||||
appId: string,
|
|
||||||
version: string = "v25.0",
|
|
||||||
): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (!appId) {
|
|
||||||
console.warn(
|
|
||||||
"[auth] initializeFacebookAuth: NEXT_PUBLIC_FACEBOOK_APP_ID is not set",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// version 占位:实际由 FacebookLoginClient.init({ version }) 消费
|
|
||||||
void version;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google OAuth 预热辅助。
|
|
||||||
*
|
|
||||||
* 原始 Dart: `lib/utils/google_auth_initializer.dart` 的 `initializeGoogleAuth()`
|
|
||||||
*
|
|
||||||
* 行为:
|
|
||||||
* - `@react-oauth/google` 的 `<GoogleOAuthProvider>` 已经负责加载 GIS 脚本;
|
|
||||||
* 本函数在 script 加载完成时记录一条调试日志(如果 env 缺失则 warn)。
|
|
||||||
* - 不发起任何网络请求;副作用仅限 console。
|
|
||||||
*
|
|
||||||
* 部署:本文件对应 Dart 端 `initializeGoogleAuth()` 调用位点;
|
|
||||||
* 真实使用时机由调用方在 `useEffect` 中安排,不需要在模块顶层自动执行。
|
|
||||||
*/
|
|
||||||
export function initializeGoogleAuth(clientId: string): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (!clientId) {
|
|
||||||
console.warn(
|
|
||||||
"[auth] initializeGoogleAuth: NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟第三方登录平台。
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/data/services/auth/mock_auth_platform.dart
|
|
||||||
*
|
|
||||||
* 在 `NEXT_PUBLIC_USE_MOCK_AUTH=true` 时由 `AuthPlatformFactory` 注入。
|
|
||||||
* 返回固定的 idToken / accessToken / 用户信息,附 250ms 模拟延迟便于观察 loading
|
|
||||||
* 状态。
|
|
||||||
*/
|
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
|
||||||
import { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
|
||||||
import type {
|
|
||||||
IAuthPlatform,
|
|
||||||
GoogleCredential,
|
|
||||||
FacebookLoginResult,
|
|
||||||
} from "./auth_platform";
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MockAuthPlatform implements IAuthPlatform {
|
|
||||||
constructor(private readonly simulatedDelayMs: number = 250) {}
|
|
||||||
|
|
||||||
async googleSignIn(): Promise<ResultT<GoogleCredential>> {
|
|
||||||
await sleep(this.simulatedDelayMs);
|
|
||||||
return Result.ok({
|
|
||||||
idToken: `mock_google_id_token_${Date.now()}`,
|
|
||||||
email: "mock@example.com",
|
|
||||||
displayName: "Mock User",
|
|
||||||
photoUrl: "https://via.placeholder.com/150",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
supportsGoogleAuthenticate(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async facebookSignIn(): Promise<ResultT<FacebookLoginResult>> {
|
|
||||||
await sleep(this.simulatedDelayMs);
|
|
||||||
return Result.ok({
|
|
||||||
status: "success",
|
|
||||||
accessToken: `mock_facebook_token_${Date.now()}`,
|
|
||||||
userId: "mock_facebook_user_id",
|
|
||||||
expiresAt: Date.now() + 3600_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFacebookUserData(): Promise<ResultT<FacebookUserData>> {
|
|
||||||
await sleep(this.simulatedDelayMs);
|
|
||||||
return Result.ok(
|
|
||||||
FacebookUserData.from({
|
|
||||||
id: "mock_facebook_id",
|
|
||||||
name: "Mock User",
|
|
||||||
email: "mock@example.com",
|
|
||||||
pictureUrl: "https://via.placeholder.com/150",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web 平台真机实现。
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/data/services/auth/auth_platform_web.dart
|
|
||||||
*
|
|
||||||
* 类本身是 plain TS,不调用 React 钩子;通过 `auth_event_bridge` 派发
|
|
||||||
* `cozsweet:auth:*:start` 事件,由 `AuthPlatformBridge` 持有 GIS / FB SDK 钩子
|
|
||||||
* 并在拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。
|
|
||||||
*
|
|
||||||
* SSR 安全:
|
|
||||||
* - 构造函数、supportsGoogleAuthenticate() 在 server 端可用;
|
|
||||||
* - 业务方法在 SSR 下直接返回 failure(与 Flutter WebAuthPlatform 一致:
|
|
||||||
* 必须由用户点击触发弹窗,服务器侧无法完成 OAuth)。
|
|
||||||
*/
|
|
||||||
import { Result, toError, type Result as ResultT } from "@/utils/result";
|
|
||||||
import { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
|
||||||
import {
|
|
||||||
AUTH_EVENTS,
|
|
||||||
dispatchAuthEvent,
|
|
||||||
} from "./auth_event_bridge";
|
|
||||||
import type {
|
|
||||||
IAuthPlatform,
|
|
||||||
IAuthPlatformOptions,
|
|
||||||
GoogleCredential,
|
|
||||||
FacebookLoginResult,
|
|
||||||
} from "./auth_platform";
|
|
||||||
|
|
||||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
interface PendingResolver<T> {
|
|
||||||
resolve: (r: ResultT<T>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WebAuthPlatform implements IAuthPlatform {
|
|
||||||
readonly opts: IAuthPlatformOptions;
|
|
||||||
private readonly pendingGoogle = new Map<
|
|
||||||
string,
|
|
||||||
PendingResolver<GoogleCredential>
|
|
||||||
>();
|
|
||||||
private readonly pendingFacebook = new Map<
|
|
||||||
string,
|
|
||||||
PendingResolver<FacebookLoginResult>
|
|
||||||
>();
|
|
||||||
private nextId = 0;
|
|
||||||
|
|
||||||
constructor(opts: IAuthPlatformOptions) {
|
|
||||||
this.opts = opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 由 AuthPlatformBridge 在拿到 GIS 凭证后回调。 */
|
|
||||||
resolveGoogle(
|
|
||||||
requestId: string,
|
|
||||||
result: ResultT<GoogleCredential>,
|
|
||||||
): void {
|
|
||||||
const pending = this.pendingGoogle.get(requestId);
|
|
||||||
if (pending) {
|
|
||||||
pending.resolve(result);
|
|
||||||
this.pendingGoogle.delete(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 由 AuthPlatformBridge 在拿到 FB login response 后回调。 */
|
|
||||||
resolveFacebook(
|
|
||||||
requestId: string,
|
|
||||||
result: ResultT<FacebookLoginResult>,
|
|
||||||
): void {
|
|
||||||
const pending = this.pendingFacebook.get(requestId);
|
|
||||||
if (pending) {
|
|
||||||
pending.resolve(result);
|
|
||||||
this.pendingFacebook.delete(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async googleSignIn(): Promise<ResultT<GoogleCredential>> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return Result.err(new Error("googleSignIn requires a browser"));
|
|
||||||
}
|
|
||||||
if (!this.opts.googleClientId) {
|
|
||||||
return Result.err(
|
|
||||||
new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const requestId = this.allocId();
|
|
||||||
return new Promise<ResultT<GoogleCredential>>((resolve) => {
|
|
||||||
this.pendingGoogle.set(requestId, { resolve });
|
|
||||||
dispatchAuthEvent(AUTH_EVENTS.googleStart, { requestId });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.pendingGoogle.has(requestId)) {
|
|
||||||
this.pendingGoogle.delete(requestId);
|
|
||||||
resolve(Result.err(new Error("Google sign-in timed out")));
|
|
||||||
}
|
|
||||||
}, TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web 平台使用 GIS 弹窗流程,`google.accounts.id.prompt()`,不调用
|
|
||||||
* "authenticate" API。返回 false 以对齐 Dart `WebAuthPlatform` 行为。
|
|
||||||
*/
|
|
||||||
supportsGoogleAuthenticate(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async facebookSignIn(): Promise<ResultT<FacebookLoginResult>> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return Result.err(new Error("facebookSignIn requires a browser"));
|
|
||||||
}
|
|
||||||
if (!this.opts.facebookAppId) {
|
|
||||||
return Result.err(
|
|
||||||
new Error("NEXT_PUBLIC_FACEBOOK_APP_ID is not set"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const requestId = this.allocId();
|
|
||||||
return new Promise<ResultT<FacebookLoginResult>>((resolve) => {
|
|
||||||
this.pendingFacebook.set(requestId, { resolve });
|
|
||||||
dispatchAuthEvent(AUTH_EVENTS.facebookStart, { requestId });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.pendingFacebook.has(requestId)) {
|
|
||||||
this.pendingFacebook.delete(requestId);
|
|
||||||
resolve(Result.err(new Error("Facebook sign-in timed out")));
|
|
||||||
}
|
|
||||||
}, TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取 Facebook 用户数据。
|
|
||||||
*
|
|
||||||
* 与 Dart `WebAuthPlatform.getFacebookUserData()` 对齐:调用 FB JS SDK 的
|
|
||||||
* `FB.api('/me', { fields })`。前提是 Bridge 已经 `FacebookLoginClient.init`
|
|
||||||
* 初始化过 SDK(`window.FB` 全局存在)。若 SDK 未加载则返回 failure。
|
|
||||||
*/
|
|
||||||
async getFacebookUserData(): Promise<ResultT<FacebookUserData>> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return Result.err(
|
|
||||||
new Error("getFacebookUserData requires a browser"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const FB = (window as unknown as { FB?: unknown }).FB;
|
|
||||||
if (!FB) {
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"Facebook SDK not loaded; call facebookSignIn() first or mount <AuthPlatformBridge>",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new Promise<ResultT<FacebookUserData>>((resolve) => {
|
|
||||||
try {
|
|
||||||
(window as unknown as {
|
|
||||||
FB: {
|
|
||||||
api: (
|
|
||||||
path: string,
|
|
||||||
params: Record<string, string>,
|
|
||||||
cb: (resp: Record<string, unknown>) => void,
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
}).FB.api(
|
|
||||||
"/me",
|
|
||||||
{ fields: "id,name,email,picture" },
|
|
||||||
(resp) => {
|
|
||||||
if (!resp || (resp as { error?: unknown }).error) {
|
|
||||||
resolve(Result.err(new Error("FB.api /me failed")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const r = resp as {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
picture?: { data?: { url?: string } };
|
|
||||||
};
|
|
||||||
resolve(
|
|
||||||
Result.ok(
|
|
||||||
FacebookUserData.from({
|
|
||||||
id: r.id ?? null,
|
|
||||||
name: r.name ?? null,
|
|
||||||
email: r.email ?? null,
|
|
||||||
pictureUrl: r.picture?.data?.url ?? null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
resolve(Result.err(toError(e)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private allocId(): string {
|
|
||||||
this.nextId += 1;
|
|
||||||
return `auth-${Date.now()}-${this.nextId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,7 @@ export * from "../../core/net/config/api_config";
|
|||||||
export * from "./api/interceptor/auth_refresh_interceptor";
|
export * from "./api/interceptor/auth_refresh_interceptor";
|
||||||
export * from "./api/interceptor/logging_interceptor";
|
export * from "./api/interceptor/logging_interceptor";
|
||||||
export * from "./api/interceptor/token_interceptor";
|
export * from "./api/interceptor/token_interceptor";
|
||||||
export * from "./auth/auth_bridge_component";
|
// 注:原 ./auth/* 平台适配层已删除(社交登录改由 NextAuth 处理)
|
||||||
export * from "./auth/auth_event_bridge";
|
|
||||||
export * from "./auth/auth_platform";
|
|
||||||
export * from "./auth/global";
|
|
||||||
export * from "./auth/initialize_facebook_auth";
|
|
||||||
export * from "./auth/initialize_google_auth";
|
|
||||||
export * from "./auth/mock_auth_platform";
|
|
||||||
export * from "./auth/web_auth_platform";
|
|
||||||
export * from "../dto/auth/apple_login_request";
|
export * from "../dto/auth/apple_login_request";
|
||||||
export * from "../dto/auth/facebook_login_request";
|
export * from "../dto/auth/facebook_login_request";
|
||||||
export * from "../dto/auth/facebook_user_data";
|
export * from "../dto/auth/facebook_user_data";
|
||||||
|
|||||||
Vendored
-39
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Facebook SDK 全局类型声明
|
|
||||||
*/
|
|
||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
FB?: {
|
|
||||||
init(params: {
|
|
||||||
appId: string;
|
|
||||||
cookie?: boolean;
|
|
||||||
xfbml?: boolean;
|
|
||||||
version: string;
|
|
||||||
}): void;
|
|
||||||
login(
|
|
||||||
callback: (response: {
|
|
||||||
authResponse?: {
|
|
||||||
accessToken: string;
|
|
||||||
userID: string;
|
|
||||||
expiresIn: number;
|
|
||||||
};
|
|
||||||
status?: "connected" | "not_authorized" | "unknown";
|
|
||||||
}) => void,
|
|
||||||
options?: { scope: string },
|
|
||||||
): void;
|
|
||||||
logout(callback?: () => void): void;
|
|
||||||
api(
|
|
||||||
path: string,
|
|
||||||
callback: (response: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
picture?: { data?: { url?: string } };
|
|
||||||
}) => void,
|
|
||||||
): void;
|
|
||||||
};
|
|
||||||
fbAsyncInit?: () => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Facebook SDK JS 封装
|
|
||||||
*
|
|
||||||
* 原始 Dart: `flutter_facebook_auth` 包
|
|
||||||
* (`lib/data/services/auth/auth_platform.dart` + `auth_screen.dart` 中 Facebook 登录入口)。
|
|
||||||
*
|
|
||||||
* 浏览器方案:
|
|
||||||
* 1. 在 `app/layout.tsx` 通过 `<Script src="https://connect.facebook.net/en_US/sdk.js">` 注入。
|
|
||||||
* 2. 调用 `FB.login` / `FB.getUserData` 完成 OAuth + 用户信息获取。
|
|
||||||
* 3. TypeScript 类型声明在 `facebook-sdk.d.ts`(`window.FB`)。
|
|
||||||
*/
|
|
||||||
import { Result } from "@/utils/result";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
FB?: {
|
|
||||||
init(params: {
|
|
||||||
appId: string;
|
|
||||||
cookie?: boolean;
|
|
||||||
xfbml?: boolean;
|
|
||||||
version: string;
|
|
||||||
}): void;
|
|
||||||
login(
|
|
||||||
callback: (response: {
|
|
||||||
authResponse?: {
|
|
||||||
accessToken: string;
|
|
||||||
userID: string;
|
|
||||||
expiresIn: number;
|
|
||||||
};
|
|
||||||
status?: "connected" | "not_authorized" | "unknown";
|
|
||||||
}) => void,
|
|
||||||
options?: { scope: string },
|
|
||||||
): void;
|
|
||||||
logout(callback?: () => void): void;
|
|
||||||
api(
|
|
||||||
path: string,
|
|
||||||
callback: (response: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
picture?: { data?: { url?: string } };
|
|
||||||
}) => void,
|
|
||||||
): void;
|
|
||||||
};
|
|
||||||
fbAsyncInit?: () => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FacebookUserData {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
pictureUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FB_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "";
|
|
||||||
const FB_VERSION = process.env.NEXT_PUBLIC_FACEBOOK_API_VERSION ?? "v18.0";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 FB SDK(在 `window.fbAsyncInit` 触发时调用一次)。
|
|
||||||
* 由 `app/layout.tsx` 的 inline script 触发。
|
|
||||||
*/
|
|
||||||
export function initFacebookSdk(): void {
|
|
||||||
if (typeof window === "undefined" || !window.FB) return;
|
|
||||||
window.FB.init({
|
|
||||||
appId: FB_APP_ID,
|
|
||||||
cookie: true,
|
|
||||||
xfbml: false,
|
|
||||||
version: FB_VERSION,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 触发 Facebook OAuth 登录。
|
|
||||||
* - 成功返回 `{ accessToken, userId }`
|
|
||||||
* - 取消 / 失败返回 `Result.err`
|
|
||||||
*/
|
|
||||||
export async function loginWithFacebook(): Promise<
|
|
||||||
Result<{ accessToken: string; userId: string }>
|
|
||||||
> {
|
|
||||||
if (typeof window === "undefined" || !window.FB) {
|
|
||||||
return Result.err(new Error("Facebook SDK not loaded"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window.FB!.login(
|
|
||||||
(response) => {
|
|
||||||
if (!response.authResponse) {
|
|
||||||
resolve(
|
|
||||||
Result.err(new Error("Facebook login cancelled or failed")),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(
|
|
||||||
Result.ok({
|
|
||||||
accessToken: response.authResponse.accessToken,
|
|
||||||
userId: response.authResponse.userID,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ scope: "email,public_profile" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取 Facebook 用户信息(头像、姓名等)。
|
|
||||||
*/
|
|
||||||
export async function getFacebookUserData(): Promise<
|
|
||||||
Result<FacebookUserData>
|
|
||||||
> {
|
|
||||||
if (typeof window === "undefined" || !window.FB) {
|
|
||||||
return Result.err(new Error("Facebook SDK not loaded"));
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window.FB!.api("/me", (response) => {
|
|
||||||
if (!response.id) {
|
|
||||||
resolve(Result.err(new Error("No Facebook user data")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 单独拉取头像
|
|
||||||
window.FB!.api(
|
|
||||||
"/me/picture?type=large&redirect=false",
|
|
||||||
(picResp: unknown) => {
|
|
||||||
const url =
|
|
||||||
picResp &&
|
|
||||||
typeof picResp === "object" &&
|
|
||||||
"data" in picResp &&
|
|
||||||
picResp.data &&
|
|
||||||
typeof picResp.data === "object" &&
|
|
||||||
"url" in picResp.data &&
|
|
||||||
typeof picResp.data.url === "string"
|
|
||||||
? picResp.data.url
|
|
||||||
: undefined;
|
|
||||||
resolve(
|
|
||||||
Result.ok({
|
|
||||||
id: response.id!,
|
|
||||||
name: response.name!,
|
|
||||||
email: response.email!,
|
|
||||||
pictureUrl: url,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Google Identity Services 全局类型声明
|
|
||||||
*/
|
|
||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
google?: {
|
|
||||||
accounts: {
|
|
||||||
id: {
|
|
||||||
initialize(params: {
|
|
||||||
client_id: string;
|
|
||||||
callback: (response: { credential?: string; select_by?: string }) => void;
|
|
||||||
auto_select?: boolean;
|
|
||||||
cancel_on_tap_outside?: boolean;
|
|
||||||
}): void;
|
|
||||||
prompt(): void;
|
|
||||||
renderButton(
|
|
||||||
parent: HTMLElement,
|
|
||||||
options: {
|
|
||||||
type?: "standard" | "icon";
|
|
||||||
theme?: "outline" | "filled_blue" | "filled_black";
|
|
||||||
size?: "large" | "medium" | "small";
|
|
||||||
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
|
||||||
shape?: "rectangular" | "pill" | "circle" | "square";
|
|
||||||
logo_alignment?: "left" | "center";
|
|
||||||
width?: number;
|
|
||||||
locale?: string;
|
|
||||||
},
|
|
||||||
): void;
|
|
||||||
disableAutoSelect(): void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Google Identity Services (GIS) 封装
|
|
||||||
*
|
|
||||||
* 原始 Dart: `google_sign_in_web` 包
|
|
||||||
* (`lib/ui/auth/widgets/google_sign_in_web.dart` + `auth_bloc._onWebGoogleLoginSuccess`)。
|
|
||||||
*
|
|
||||||
* 浏览器方案:
|
|
||||||
* 1. `<Script src="https://accounts.google.com/gsi/client">` 在 layout 注入。
|
|
||||||
* 2. `renderButton(element, options)` 渲染 GIS 按钮(自动处理 OAuth 流程)。
|
|
||||||
* 3. 登录成功 callback 回调 `onGoogleSignInSuccess(idToken, email)`。
|
|
||||||
* 4. 把 idToken 传给 `authRepository.googleLogin`。
|
|
||||||
*
|
|
||||||
* 全局类型:`google.accounts.id` 在 `google-identity.d.ts` 中声明。
|
|
||||||
*/
|
|
||||||
import { Result } from "@/utils/result";
|
|
||||||
|
|
||||||
export interface GoogleSignInSuccess {
|
|
||||||
idToken: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _onSuccess: ((s: GoogleSignInSuccess) => void) | null = null;
|
|
||||||
|
|
||||||
/** 设置全局成功回调(auth screen 在 mount 时设置,unmount 时清空)。 */
|
|
||||||
export function setOnGoogleSignInSuccess(
|
|
||||||
cb: ((s: GoogleSignInSuccess) => void) | null,
|
|
||||||
): void {
|
|
||||||
_onSuccess = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 渲染 GIS 按钮到指定 DOM 元素。 */
|
|
||||||
export function renderGoogleButton(
|
|
||||||
element: HTMLElement,
|
|
||||||
options: { theme?: "outline" | "filled_blue" | "filled_black"; size?: "large" | "medium" | "small"; text?: "signin_with" | "signup_with" | "continue_with" | "signin" } = {},
|
|
||||||
): void {
|
|
||||||
if (typeof window === "undefined" || !window.google?.accounts?.id) return;
|
|
||||||
window.google.accounts.id.renderButton(element, {
|
|
||||||
type: "standard",
|
|
||||||
theme: options.theme ?? "outline",
|
|
||||||
size: options.size ?? "large",
|
|
||||||
text: options.text ?? "continue_with",
|
|
||||||
shape: "pill",
|
|
||||||
width: element.clientWidth || 320,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 触发 GIS One Tap 提示(如果用户已登录过 Google)。 */
|
|
||||||
export function promptGoogleOneTap(): Result<true> {
|
|
||||||
if (typeof window === "undefined" || !window.google?.accounts?.id) {
|
|
||||||
return Result.err(new Error("Google Identity Services not loaded"));
|
|
||||||
}
|
|
||||||
window.google.accounts.id.prompt();
|
|
||||||
return Result.ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理 GIS 凭据回调(由 GIS 内部调用)。 */
|
|
||||||
export function handleGoogleCredentialResponse(
|
|
||||||
response: { credential?: string; select_by?: string },
|
|
||||||
): Result<GoogleSignInSuccess> {
|
|
||||||
if (!response.credential) {
|
|
||||||
return Result.err(new Error("No Google credential returned"));
|
|
||||||
}
|
|
||||||
// GIS 凭据是 JWT;本仓库不解析(idToken 直接交给后端校验),
|
|
||||||
// email 字段需前端手动解码或由后端从 idToken 提取。
|
|
||||||
// 此处仅返回 idToken,email 留空(后端会从 idToken 还原)。
|
|
||||||
const success: GoogleSignInSuccess = {
|
|
||||||
idToken: response.credential,
|
|
||||||
email: "",
|
|
||||||
};
|
|
||||||
_onSuccess?.(success);
|
|
||||||
return Result.ok(success);
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,5 @@
|
|||||||
|
|
||||||
export * from "./browser-detect";
|
export * from "./browser-detect";
|
||||||
export * from "./chat-websocket";
|
export * from "./chat-websocket";
|
||||||
export * from "./facebook-sdk";
|
|
||||||
export * from "./facebook-sdk";
|
|
||||||
export * from "./google-identity";
|
|
||||||
export * from "./google-identity";
|
|
||||||
export * from "./media-recorder";
|
export * from "./media-recorder";
|
||||||
export * from "./message-queue";
|
export * from "./message-queue";
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* NextAuth 配置
|
||||||
|
*
|
||||||
|
* 集中管理 OAuth providers(Google + Facebook)+ session 策略 + 回调。
|
||||||
|
* 由 `src/app/api/auth/[...nextauth]/route.ts` 引用。
|
||||||
|
*
|
||||||
|
* Session 策略:**JWT**(无服务端存储;token 签名 / 过期由 next-auth 内部处理)。
|
||||||
|
* 业务层 token(用于后端鉴权)通过 `signIn` callback 写入 unstorage `StorageKeys.loginToken`。
|
||||||
|
*/
|
||||||
|
import type { NextAuthOptions } from "next-auth";
|
||||||
|
import GoogleProvider from "next-auth/providers/google";
|
||||||
|
import FacebookProvider from "next-auth/providers/facebook";
|
||||||
|
|
||||||
|
const useSecureCookies = process.env.NODE_ENV === "production";
|
||||||
|
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
|
||||||
|
|
||||||
|
const cookieConfig = {
|
||||||
|
// 默认 cookie 名(next-auth v4 默认)
|
||||||
|
sessionToken: {
|
||||||
|
name: `${cookiePrefix}next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
secure: useSecureCookies,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
// ===== Providers =====
|
||||||
|
providers: [
|
||||||
|
GoogleProvider({
|
||||||
|
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "",
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
||||||
|
}),
|
||||||
|
FacebookProvider({
|
||||||
|
clientId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "",
|
||||||
|
clientSecret: process.env.FACEBOOK_APP_SECRET ?? "",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ===== Session =====
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
|
||||||
|
// ===== Cookie =====
|
||||||
|
cookies: cookieConfig,
|
||||||
|
|
||||||
|
// ===== Pages =====
|
||||||
|
// OAuth 流程中 NextAuth 重定向到这些路径
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth",
|
||||||
|
error: "/auth",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Callbacks =====
|
||||||
|
callbacks: {
|
||||||
|
/**
|
||||||
|
* 登录成功时同步用户到后端(写入 unstorage + 后端用户表)
|
||||||
|
* 业务层 token 持久化在 `jwt` callback 中
|
||||||
|
*/
|
||||||
|
async signIn({ user, account, profile }) {
|
||||||
|
if (!account || !user) return false;
|
||||||
|
|
||||||
|
// 仅对 OAuth provider 触发后端同步(credentials provider 走 emailLogin 流程)
|
||||||
|
if (account.provider === "google" || account.provider === "facebook") {
|
||||||
|
// 通过 dynamic import 避免循环依赖 + SSR 时不加载
|
||||||
|
const { handleSocialSignIn } = await import("./nextauth-helpers");
|
||||||
|
const result = await handleSocialSignIn({
|
||||||
|
provider: account.provider,
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT callback:在 token 中携带业务字段(loginToken、userId)
|
||||||
|
* 业务层可从 `useSession().data` 读到这些字段
|
||||||
|
*/
|
||||||
|
async jwt({ token, user, account }) {
|
||||||
|
if (user && account) {
|
||||||
|
// 首次登录时,从 signIn callback 写入的 unstorage 读取
|
||||||
|
if (account.provider === "google" || account.provider === "facebook") {
|
||||||
|
const { getSocialSession } = await import("./nextauth-helpers");
|
||||||
|
const session = await getSocialSession();
|
||||||
|
if (session) {
|
||||||
|
token.loginToken = session.loginToken;
|
||||||
|
token.refreshToken = session.refreshToken;
|
||||||
|
token.userId = session.userId;
|
||||||
|
token.provider = account.provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session callback:把 JWT 中的字段暴露给 `useSession()`
|
||||||
|
*/
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
(session as unknown as Record<string, unknown>).loginToken =
|
||||||
|
token.loginToken;
|
||||||
|
(session as unknown as Record<string, unknown>).userId = token.userId;
|
||||||
|
(session as unknown as Record<string, unknown>).provider =
|
||||||
|
token.provider;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Secret =====
|
||||||
|
secret: process.env.NEXTAUTH_SECRET ?? "dev-secret-change-in-prod",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展 next-auth Session 类型,加入业务字段
|
||||||
|
*/
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
loginToken?: string;
|
||||||
|
userId?: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* NextAuth 业务层 helpers
|
||||||
|
*
|
||||||
|
* 处理 OAuth 登录后的"业务层同步":
|
||||||
|
* 1. Google/Facebook OAuth 完成 → NextAuth 返回 user/account/profile
|
||||||
|
* 2. 用 idToken / accessToken 调后端 `authRepository.googleLogin` / `facebookLogin`
|
||||||
|
* 3. 后端返回 `LoginResponse`(含业务 token + user)
|
||||||
|
* 4. 写入 unstorage + NextAuth JWT
|
||||||
|
*
|
||||||
|
* 同时提供 `useAuthGate()` 包装(保持原 API 形状,内部用 `useSession()` 实现)。
|
||||||
|
*/
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import type { Account, Profile, User } from "next-auth";
|
||||||
|
|
||||||
|
import { authRepository } from "@/data/repositories/auth_repository";
|
||||||
|
import { SpAsyncUtil } from "@/utils/storage";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
|
|
||||||
|
/** 暴露给外部的:Google 登录 */
|
||||||
|
export async function googleLogin(): Promise<Result<{ isNewUser: boolean }>> {
|
||||||
|
// NextAuth 在客户端通过 signIn() 触发跳转;服务端场景下可通过 POST /api/auth/signin/google
|
||||||
|
// 本函数封装整个流程:触发 NextAuth OAuth → 后端同步 → 返回业务 session
|
||||||
|
const { signIn } = await import("next-auth/react");
|
||||||
|
const result = await signIn("google", {
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/chat",
|
||||||
|
});
|
||||||
|
if (!result || result.error) {
|
||||||
|
return Result.err(new Error(result?.error ?? "Google sign-in failed"));
|
||||||
|
}
|
||||||
|
return Result.ok({ isNewUser: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暴露给外部的:Facebook 登录 */
|
||||||
|
export async function facebookLogin(): Promise<Result<{ isNewUser: boolean }>> {
|
||||||
|
const { signIn } = await import("next-auth/react");
|
||||||
|
const result = await signIn("facebook", {
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/chat",
|
||||||
|
});
|
||||||
|
if (!result || result.error) {
|
||||||
|
return Result.err(new Error(result?.error ?? "Facebook sign-in failed"));
|
||||||
|
}
|
||||||
|
return Result.ok({ isNewUser: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暴露给外部的:登出 */
|
||||||
|
export async function logout(): Promise<Result<void>> {
|
||||||
|
const { signOut } = await import("next-auth/react");
|
||||||
|
await signOut({ redirect: false });
|
||||||
|
// 清理业务层 token
|
||||||
|
await SpAsyncUtil.remove(StorageKeys.loginToken);
|
||||||
|
await SpAsyncUtil.remove(StorageKeys.refreshToken);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 内部:signIn callback 用的业务层同步
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface SocialSignInInput {
|
||||||
|
provider: string;
|
||||||
|
user: User;
|
||||||
|
account: Account;
|
||||||
|
profile?: Profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth 登录完成后调后端同步用户。
|
||||||
|
* 写入 unstorage(业务 token)+ NextAuth session 字段。
|
||||||
|
*
|
||||||
|
* 返回 `true` 允许 NextAuth 完成登录;`false` 中止。
|
||||||
|
*/
|
||||||
|
export async function handleSocialSignIn(
|
||||||
|
input: SocialSignInInput,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const guestId = await readGuestId();
|
||||||
|
|
||||||
|
if (input.provider === "google") {
|
||||||
|
// Google:NextAuth 拿到的 idToken 来自 account.id_token
|
||||||
|
const idToken = input.account.id_token;
|
||||||
|
if (!idToken) {
|
||||||
|
console.error("[nextauth-helpers] Google id_token missing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const email = input.user.email ?? "";
|
||||||
|
const result = await authRepository.googleLogin({ idToken, guestId });
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
console.error("[nextauth-helpers] Google backend login failed", result.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await persistBackendSession(result.data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.provider === "facebook") {
|
||||||
|
// Facebook:accessToken 来自 account.access_token
|
||||||
|
const accessToken = input.account.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
console.error("[nextauth-helpers] Facebook access_token missing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await authRepository.facebookLogin({ accessToken, guestId });
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
console.error("[nextauth-helpers] Facebook backend login failed", result.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await persistBackendSession(result.data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[nextauth-helpers] social sign-in error", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialSession {
|
||||||
|
loginToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端返回的 LoginResponse 写入 NextAuth 可访问的临时存储。
|
||||||
|
* jwt callback 中会从这里读取。
|
||||||
|
*/
|
||||||
|
async function persistBackendSession(data: {
|
||||||
|
token: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user: { id: string };
|
||||||
|
}): Promise<void> {
|
||||||
|
await SpAsyncUtil.setString(StorageKeys.loginToken, data.token);
|
||||||
|
if (data.refreshToken) {
|
||||||
|
await SpAsyncUtil.setString(StorageKeys.refreshToken, data.refreshToken);
|
||||||
|
}
|
||||||
|
// userId 写入 unstorage(其他业务模块通过 useUserState 读)
|
||||||
|
await SpAsyncUtil.setString(StorageKeys.userId, data.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSocialSession(): Promise<SocialSession | null> {
|
||||||
|
// 从 unstorage 读取(之前 persistBackendSession 写入的)
|
||||||
|
const tokenR = await SpAsyncUtil.getString(StorageKeys.loginToken);
|
||||||
|
const userIdR = await SpAsyncUtil.getString(StorageKeys.userId);
|
||||||
|
const refreshR = await SpAsyncUtil.getString(StorageKeys.refreshToken);
|
||||||
|
if (!tokenR.success || !tokenR.data || !userIdR.success || !userIdR.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loginToken: tokenR.data,
|
||||||
|
userId: userIdR.data,
|
||||||
|
refreshToken: refreshR.success ? refreshR.data ?? undefined : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 客户端鉴权门 hook(保持原 useAuthGate 形状,内部用 useSession)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface AuthGateState {
|
||||||
|
isAuthed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthGate(): AuthGateState {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
return { isAuthed: Boolean(session?.user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 内部 helpers
|
||||||
|
// ============================================================
|
||||||
|
async function readGuestId(): Promise<string | undefined> {
|
||||||
|
const r = await SpAsyncUtil.getString(StorageKeys.deviceId);
|
||||||
|
if (r.success && r.data != null && r.data.length > 0) {
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* NextAuth 业务层主入口
|
||||||
|
*
|
||||||
|
* 对外提供两个干净的登录接口(用户要求):
|
||||||
|
* - `googleLogin()` — 触发 Google OAuth 登录
|
||||||
|
* - `facebookLogin()` — 触发 Facebook OAuth 登录
|
||||||
|
*
|
||||||
|
* 调用方(splash-button / auth-facebook-panel / splash-screen):
|
||||||
|
* ```ts
|
||||||
|
* import { googleLogin, facebookLogin, useAuthGate } from "@/lib/auth/nextauth";
|
||||||
|
*
|
||||||
|
* const r = await googleLogin();
|
||||||
|
* if (r.success) { ... }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 兼容:
|
||||||
|
* - `useAuthGate()` API 与旧 hook 完全相同(业务层零修改)
|
||||||
|
* - 内部实现用 `useSession()`(NextAuth 客户端 API)
|
||||||
|
* - 后端同步通过 `nextauth-helpers.handleSocialSignIn` 在 NextAuth signIn callback 中完成
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
googleLogin,
|
||||||
|
facebookLogin,
|
||||||
|
logout,
|
||||||
|
useAuthGate,
|
||||||
|
type AuthGateState,
|
||||||
|
} from "./nextauth-helpers";
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 纯函数路由守卫
|
|
||||||
*
|
|
||||||
* 不依赖 React/Next.js,可被 `src/proxy.ts`(边缘运行时)与
|
|
||||||
* `src/lib/auth/use-auth-gate.tsx`(客户端 hook)共用。
|
|
||||||
*
|
|
||||||
* 对齐 Flutter `lib/router/app_router.dart` 中 `_handleAuthRedirect` 的语义:
|
|
||||||
* 已登录用户访问 `/splash` 或 `/auth` 时,重定向到 `/chat`。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AUTH_ONLY_ROUTES, ROUTES, type StaticRoute } from "../routes";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判定路径是否为"仅未登录态可见"的入口路由。
|
|
||||||
*/
|
|
||||||
export function isAuthOnlyRoute(pathname: string): pathname is StaticRoute {
|
|
||||||
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判定路径是否为公开路由(游客态也允许访问)。
|
|
||||||
*/
|
|
||||||
export function isPublicRoute(pathname: string): pathname is StaticRoute {
|
|
||||||
return pathname === ROUTES.chat || pathname === ROUTES.sidebar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已登录用户访问入口路由时返回应跳转到的目标;其余情况返回 null。
|
|
||||||
*
|
|
||||||
* 当前实现仅做"splash/auth → chat"的重定向;未来若新增入口路由(如 onboarding),
|
|
||||||
* 只需在此函数追加分支。
|
|
||||||
*/
|
|
||||||
export function resolveAuthedRedirect(pathname: string): string | null {
|
|
||||||
if (isAuthOnlyRoute(pathname)) return ROUTES.chat;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客户端鉴权门 hook
|
|
||||||
*
|
|
||||||
* 单一真值 = `window.localStorage[StorageKeys.loginToken]`。
|
|
||||||
* 故意不引入 Context/Zustand:本轮只有 splash/auth 两处使用,直接 hook 调用更直白。
|
|
||||||
*
|
|
||||||
* 实现要点(React 19 / Next.js 16 约定):
|
|
||||||
* - 用 `useSyncExternalStore` 而非 `useEffect + setState`,避免 React 19
|
|
||||||
* `react-hooks/set-state-in-effect` 规则的级联渲染告警。
|
|
||||||
* - `getServerSnapshot` 返回 `false` → SSR 与客户端水合首帧都拿到一致值;
|
|
||||||
* 水合完成后 React 自动重读 `getSnapshot()`(localStorage 真值),触发单次
|
|
||||||
* 自然重渲染 → 调用方 `useEffect([isAuthed], ...)` 即捕获"已登录"事件。
|
|
||||||
* - subscribe 用空函数(no-op):localStorage 在本应用生命周期内不会主动变化
|
|
||||||
* (登录跳转走的是另一条 navigation 路径,重新进入 splash 时会重读)。
|
|
||||||
*
|
|
||||||
* 使用模式:
|
|
||||||
* const { isAuthed } = useAuthGate();
|
|
||||||
* useEffect(() => { if (isAuthed) router.replace(ROUTES.chat); }, [isAuthed]);
|
|
||||||
* if (!isAuthed) return <Placeholder />; // SSR / 水合首帧 / 未登录 都会落在这
|
|
||||||
*
|
|
||||||
* 注:本轮不暴露 `ready` 标志——`useSyncExternalStore` 自身的 SSR / 客户端差异
|
|
||||||
* 处理已经能保证水合首帧拿到 `false`(与 SSR 一致),无需调用方再判 ready。
|
|
||||||
*
|
|
||||||
* 注意:直接读 `window.localStorage` 是因为 `useSyncExternalStore.getSnapshot`
|
|
||||||
* 必须是同步函数。`AuthStorage.hasLoginToken()` 是 async,无法在此处使用。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
|
||||||
|
|
||||||
export interface AuthGateState {
|
|
||||||
/** 已登录(持有非空 `cozsweet.loginToken`) */
|
|
||||||
isAuthed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 静态 subscribe:localStorage 在本应用生命周期内不会主动变化。
|
|
||||||
const subscribe = (): (() => void) => () => {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步读取 localStorage 中的 login_token(与 `AuthStorage.hasLoginToken` 语义一致)
|
|
||||||
*/
|
|
||||||
const getSnapshot = (): boolean => {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
const token = window.localStorage.getItem(StorageKeys.loginToken);
|
|
||||||
return token !== null && token.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServerSnapshot = (): boolean => false;
|
|
||||||
|
|
||||||
export function useAuthGate(): AuthGateState {
|
|
||||||
const isAuthed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
||||||
return { isAuthed };
|
|
||||||
}
|
|
||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* `src/lib/` 手写 barrel
|
* `src/lib/` 手写 barrel
|
||||||
*
|
*
|
||||||
* 注意:故意**不**把 `src/lib/auth/use-auth-gate.tsx`(Client-only)放进来,
|
* 注意:故意**不**把 `src/lib/auth/nextauth.ts`(Client-only)放进来,
|
||||||
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
||||||
* 如需导入 hook,请直接 `import { useAuthGate } from "@/lib/auth/use-auth-gate";`。
|
* 如需导入,请直接 `import { ... } from "@/lib/auth/nextauth";`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./routes";
|
export * from "./routes";
|
||||||
export * from "./auth/route-guards";
|
export * from "./auth/nextauth-helpers";
|
||||||
|
|||||||
+28
-20
@@ -2,17 +2,15 @@
|
|||||||
* Next.js 16 Proxy(原 `middleware.ts`)
|
* Next.js 16 Proxy(原 `middleware.ts`)
|
||||||
*
|
*
|
||||||
* 行为:
|
* 行为:
|
||||||
* - 当请求携带 `login_token` cookie 且命中 `/splash` 或 `/auth` 时,308 重定向到 `/chat`。
|
* - 当请求携带 NextAuth `next-auth.session-token` cookie 且命中 `/splash` 或 `/auth`
|
||||||
|
* 时,308 重定向到 `/chat`。
|
||||||
* - 其他情况透传(`NextResponse.next()`)。
|
* - 其他情况透传(`NextResponse.next()`)。
|
||||||
*
|
*
|
||||||
* 重要约束:
|
* 重要约束:
|
||||||
* - **不**对 `/chat` 做反向重定向:游客态是合法用户(`auth_refresh_interceptor`
|
* - **不**对 `/chat` 做反向重定向:游客态是合法用户。
|
||||||
* 同时处理登录与游客 token),proxy 端无法识别 `guest_token`。
|
* - NextAuth 的 session cookie 由 `src/lib/auth/config.ts` 集中管理;本 proxy 仅做
|
||||||
* - 当前没有 `login_token` cookie 被写入(token 走 localStorage),proxy 实际是 no-op;
|
* CDN 友好的"乐观"快路径。
|
||||||
* 保留骨架便于未来 HttpOnly Cookie 迁移(`src/data/storage/auth/auth_storage.ts`
|
* - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()`)。
|
||||||
* 的 TODO 标记)时无需重构 proxy。
|
|
||||||
* - 边缘代理**读不到 localStorage**,因此真正的鉴权门是 Client 侧的
|
|
||||||
* `useAuthGate` + `authStorage.hasLoginToken()`,proxy 只是 CDN 友好的"乐观"快路径。
|
|
||||||
*
|
*
|
||||||
* Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
|
* Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
|
||||||
* - 文件从 `middleware.ts` 重命名为 `proxy.ts`;`middleware` 仍可用但已 deprecated。
|
* - 文件从 `middleware.ts` 重命名为 `proxy.ts`;`middleware` 仍可用但已 deprecated。
|
||||||
@@ -25,26 +23,36 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { resolveAuthedRedirect } from "@/lib/auth/route-guards";
|
import {
|
||||||
|
AUTH_ONLY_ROUTES,
|
||||||
|
ROUTES,
|
||||||
|
type StaticRoute,
|
||||||
|
} from "@/lib/routes";
|
||||||
|
|
||||||
const LOGIN_COOKIE = "login_token";
|
const SESSION_COOKIE_NAMES = [
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAuthOnlyRoute(pathname: string): pathname is StaticRoute {
|
||||||
|
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy 入口
|
* Proxy 入口
|
||||||
*/
|
*/
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const hasLoginCookie = Boolean(request.cookies.get(LOGIN_COOKIE)?.value);
|
|
||||||
|
|
||||||
if (hasLoginCookie) {
|
// 检查 NextAuth session cookie 存在性(cookie value 存在即认为已登录)
|
||||||
const target = resolveAuthedRedirect(pathname);
|
const hasSession = SESSION_COOKIE_NAMES.some(
|
||||||
if (target) {
|
(name) => Boolean(request.cookies.get(name)?.value),
|
||||||
const url = request.nextUrl.clone();
|
);
|
||||||
url.pathname = target;
|
|
||||||
// 308 = 永久。注销后若浏览器/CDN 缓存了 308,可能黏住。
|
if (hasSession && isAuthOnlyRoute(pathname)) {
|
||||||
// 若担心可改 307(临时);本轮按 plan 口径用 308 并在 plan 中记录风险。
|
const url = request.nextUrl.clone();
|
||||||
return NextResponse.redirect(url, 308);
|
url.pathname = ROUTES.chat;
|
||||||
}
|
return NextResponse.redirect(url, 308);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
|||||||
@@ -55,10 +55,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
password: state.context.password,
|
password: state.context.password,
|
||||||
username: state.context.username,
|
username: state.context.username,
|
||||||
confirmPassword: state.context.confirmPassword,
|
confirmPassword: state.context.confirmPassword,
|
||||||
|
// isLoading 仅来自邮箱登录 actor(社交登录改由 NextAuth 处理,UI 自行管 busy 状态)
|
||||||
isLoading: state.matches("loadingEmailLogin") ||
|
isLoading: state.matches("loadingEmailLogin") ||
|
||||||
state.matches("loadingEmailRegister") ||
|
state.matches("loadingEmailRegister"),
|
||||||
state.matches("loadingFacebookLogin") ||
|
|
||||||
state.matches("loadingWebGoogleLogin"),
|
|
||||||
errorMessage: state.context.errorMessage,
|
errorMessage: state.context.errorMessage,
|
||||||
isSuccess: state.matches("success"),
|
isSuccess: state.matches("success"),
|
||||||
loginType: state.context.loginType,
|
loginType: state.context.loginType,
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart
|
* 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart
|
||||||
*
|
*
|
||||||
* 设计要点:
|
* 本轮迁移:社交登录改由 NextAuth 处理(`@/lib/auth/nextauth`),状态机仅保留
|
||||||
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
|
* 邮箱注册/登录 + 通用 reset 事件。社交登录直接 `await nextauth.googleLogin()` 即可。
|
||||||
* - 异步副作用用 `fromPromise` actor 包装(自动取消、错误捕获)
|
|
||||||
* - 表单字段值放在 `context`,UI 通过事件更新
|
|
||||||
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
|
|
||||||
*/
|
*/
|
||||||
import { setup, fromPromise, assign } from "xstate";
|
import { setup, fromPromise, assign } from "xstate";
|
||||||
|
|
||||||
@@ -16,10 +13,6 @@ import type { AuthPanelMode } from "@/models/auth/auth-panel-mode";
|
|||||||
import type { LoginType } from "@/models/auth/login-type";
|
import type { LoginType } from "@/models/auth/login-type";
|
||||||
import { authRepository } from "@/data/repositories/auth_repository";
|
import { authRepository } from "@/data/repositories/auth_repository";
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import {
|
|
||||||
loginWithFacebook,
|
|
||||||
getFacebookUserData,
|
|
||||||
} from "@/integrations/facebook-sdk";
|
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -67,18 +60,14 @@ export type AuthEvent =
|
|||||||
username: string;
|
username: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
}
|
}
|
||||||
|
// 社交登录(已迁移到 NextAuth)— 业务层直接 await nextauth.googleLogin() 即可
|
||||||
| { type: "AuthGoogleLoginSubmitted" }
|
| { type: "AuthGoogleLoginSubmitted" }
|
||||||
| { type: "AuthFacebookLoginSubmitted" }
|
| { type: "AuthFacebookLoginSubmitted" }
|
||||||
| { type: "AuthAppleLoginSubmitted" }
|
| { type: "AuthAppleLoginSubmitted" };
|
||||||
| { type: "AuthWebGoogleLoginSuccess"; idToken: string; email: string };
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
/**
|
|
||||||
* 从存储读取 guestId,失败或缺失返回 undefined
|
|
||||||
* 用 if 语句避免 TS 联合类型 narrowing 问题
|
|
||||||
*/
|
|
||||||
async function readGuestId(): Promise<string | undefined> {
|
async function readGuestId(): Promise<string | undefined> {
|
||||||
const r = await AuthStorage.getInstance().getDeviceId();
|
const r = await AuthStorage.getInstance().getDeviceId();
|
||||||
if (r.success && r.data != null) {
|
if (r.success && r.data != null) {
|
||||||
@@ -118,37 +107,6 @@ const emailRegisterThenLoginActor = fromPromise<
|
|||||||
return "email" as LoginType;
|
return "email" as LoginType;
|
||||||
});
|
});
|
||||||
|
|
||||||
const googleLoginActor = fromPromise<LoginType, { idToken: string }>(async ({ input }) => {
|
|
||||||
const guestId = await readGuestId();
|
|
||||||
const result = await authRepository.googleLogin({ idToken: input.idToken, guestId });
|
|
||||||
if (Result.isErr(result)) throw result.error;
|
|
||||||
return "google" as LoginType;
|
|
||||||
});
|
|
||||||
|
|
||||||
const facebookLoginActor = fromPromise<LoginType, Record<string, never>>(
|
|
||||||
async () => {
|
|
||||||
const guestId = await readGuestId();
|
|
||||||
|
|
||||||
const fbResult = await loginWithFacebook();
|
|
||||||
if (Result.isErr(fbResult)) throw fbResult.error;
|
|
||||||
|
|
||||||
const authResult = await authRepository.facebookLogin({
|
|
||||||
accessToken: fbResult.data.accessToken,
|
|
||||||
guestId,
|
|
||||||
});
|
|
||||||
if (Result.isErr(authResult)) throw authResult.error;
|
|
||||||
|
|
||||||
// best-effort:异步上传 Facebook 头像
|
|
||||||
void (async () => {
|
|
||||||
const userData = await getFacebookUserData();
|
|
||||||
if (Result.isOk(userData) && userData.data.pictureUrl) {
|
|
||||||
// TODO: 调用 userStorage.setAvatarUrl
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return "facebook" as LoginType;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Machine
|
// Machine
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -160,8 +118,6 @@ export const authMachine = setup({
|
|||||||
actors: {
|
actors: {
|
||||||
emailLogin: emailLoginActor,
|
emailLogin: emailLoginActor,
|
||||||
emailRegisterThenLogin: emailRegisterThenLoginActor,
|
emailRegisterThenLogin: emailRegisterThenLoginActor,
|
||||||
googleLogin: googleLoginActor,
|
|
||||||
facebookLogin: facebookLoginActor,
|
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: "auth",
|
id: "auth",
|
||||||
@@ -196,18 +152,15 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: {
|
// 社交登录已迁移到 NextAuth,业务层直接 await nextauth.googleLogin() / nextauth.facebookLogin()
|
||||||
actions: assign({
|
// 状态机保留事件以兼容旧调用方(实际不再触发)
|
||||||
errorMessage: "Use the Google button to sign in on web",
|
AuthGoogleLoginSubmitted: {},
|
||||||
}),
|
AuthFacebookLoginSubmitted: {},
|
||||||
},
|
|
||||||
AuthFacebookLoginSubmitted: "loadingFacebookLogin",
|
|
||||||
AuthAppleLoginSubmitted: {
|
AuthAppleLoginSubmitted: {
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: "Apple login not implemented",
|
errorMessage: "Apple login not implemented",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
AuthWebGoogleLoginSuccess: "loadingWebGoogleLogin",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -266,55 +219,8 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
loadingFacebookLogin: {
|
|
||||||
invoke: {
|
|
||||||
src: "facebookLogin",
|
|
||||||
input: () => ({}),
|
|
||||||
onDone: {
|
|
||||||
target: "success",
|
|
||||||
actions: assign({
|
|
||||||
loginType: ({ event }) => event.output,
|
|
||||||
errorMessage: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "idle",
|
|
||||||
actions: assign({
|
|
||||||
errorMessage: ({ event }) =>
|
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
loadingWebGoogleLogin: {
|
|
||||||
invoke: {
|
|
||||||
src: "googleLogin",
|
|
||||||
input: ({ event }) => {
|
|
||||||
if (event.type !== "AuthWebGoogleLoginSuccess")
|
|
||||||
return { idToken: "" };
|
|
||||||
return { idToken: event.idToken };
|
|
||||||
},
|
|
||||||
onDone: {
|
|
||||||
target: "success",
|
|
||||||
actions: assign({
|
|
||||||
loginType: ({ event }) => event.output,
|
|
||||||
errorMessage: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "idle",
|
|
||||||
actions: assign({
|
|
||||||
errorMessage: ({ event }) =>
|
|
||||||
event.error instanceof Error ? event.error.message : String(event.error),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
success: {
|
success: {
|
||||||
// 终态:业务层通过 state.matches("success") 判断
|
// 终态:业务层通过 state.matches("success") 判断
|
||||||
// isSuccess 由 context 推算(loginType !== "none")
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user