fix(auth): migrate frontend to Better Auth

This commit is contained in:
2026-09-28 13:14:01 +08:00
parent 015e0dd227
commit 0fdd94317e
13 changed files with 456 additions and 86 deletions
+359
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.7.6",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.34", "vue": "^3.5.34",
@@ -81,6 +82,135 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@better-auth/core": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/core/-/core-1.7.6.tgz",
"integrity": "sha512-yBDDO0J4VCHT1qEZtzj6aMexX4etyLesitoxehcWHzPrzYqrFHwe/hgskye+imlGhQVOysuljMg8M8BA6GrtIg==",
"license": "MIT",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.41.1",
"@standard-schema/spec": "^1.1.0",
"zod": "^4.5.4"
},
"peerDependencies": {
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2",
"@opentelemetry/api": "^1.9.0",
"better-call": "1.4.0",
"jose": "^6.1.0",
"kysely": "^0.28.5 || ^0.29.0",
"nanostores": "^1.0.1"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@better-auth/drizzle-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.6.tgz",
"integrity": "sha512-ENlhwC7kkTjquuiel3Efggik0hC4vtD7ppJ8FhsiHhRJA8K6RT4hbhzkfm6keNvjdnAeWwCejm7FmMO4GoSGpA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
},
"node_modules/@better-auth/kysely-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/kysely-adapter/-/kysely-adapter-1.7.6.tgz",
"integrity": "sha512-BV+DX2/z/6ozNC9DYSge78wjLerq6Bxd/lSPjzzuq9eEutkQDAJTuiaETzgGYqNV5LlEWi8TZFAQTEq/wavOEQ==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"kysely": "^0.28.17 || ^0.29.0"
},
"peerDependenciesMeta": {
"kysely": {
"optional": true
}
}
},
"node_modules/@better-auth/memory-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/memory-adapter/-/memory-adapter-1.7.6.tgz",
"integrity": "sha512-ng1xv8k2JsEIjnqzJzdoVOQrnoCLRuk4EiQL9yYAThyg6zYzV00LynlhH+O9OMUZ+yGVpGqGZmr5xcTiUSV2uA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2"
}
},
"node_modules/@better-auth/mongo-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/mongo-adapter/-/mongo-adapter-1.7.6.tgz",
"integrity": "sha512-GvBwZli8i4XINMAj9tlW7eV9E8Dr+JWqavXjUs2o81GZt/Vh8wiX+31KK1jQ4iOuejfjpLoXcoFtlIPgf/pVog==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"mongodb": "^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"mongodb": {
"optional": true
}
}
},
"node_modules/@better-auth/prisma-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/prisma-adapter/-/prisma-adapter-1.7.6.tgz",
"integrity": "sha512-31rz2OXIx2Q1L4Jhd5F71dafIWqder/Bo6vJ+eCgO5iZ0gvnv7fUmM95DvbVUoPC69XYpcIM/6PJmQ3M6RydWg==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@prisma/client": {
"optional": true
},
"prisma": {
"optional": true
}
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/telemetry/-/telemetry-1.7.6.tgz",
"integrity": "sha512-xgRwPja2wTJtcu+OQZR2IiNpAmviddnW3n5Do5umXRugJFW3v9KlSmYjYm580mVsPnU4upvZkvSRl9v8DgSDtA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2"
}
},
"node_modules/@better-auth/utils": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.4.2.tgz",
"integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/@better-fetch/fetch": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@better-fetch/fetch/-/fetch-1.3.2.tgz",
"integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==",
"license": "MIT"
},
"node_modules/@css-render/plugin-bem": { "node_modules/@css-render/plugin-bem": {
"version": "0.15.14", "version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
@@ -213,6 +343,39 @@
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.43.0",
"resolved": "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.133.0", "version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -505,6 +668,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1189,6 +1358,136 @@
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-auth": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/better-auth/-/better-auth-1.7.6.tgz",
"integrity": "sha512-WTMqOpmTTj+oBoX7zzPOzL85342Upyf+/v0IkH1kvGRA85lV4JGRFIYMIRKqvjZ6ShsuL5VX/c9C13jtoZXcKA==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.7.6",
"@better-auth/drizzle-adapter": "1.7.6",
"@better-auth/kysely-adapter": "1.7.6",
"@better-auth/memory-adapter": "1.7.6",
"@better-auth/mongo-adapter": "1.7.6",
"@better-auth/prisma-adapter": "1.7.6",
"@better-auth/telemetry": "1.7.6",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2",
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"better-call": "1.4.0",
"defu": "^6.1.4",
"jose": "^6.2.3",
"kysely": "^0.28.17 || ^0.29.0",
"nanostores": "^1.3.0",
"zod": "^4.5.4"
},
"peerDependencies": {
"@lynx-js/react": "*",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"@sveltejs/kit": "^2.0.0",
"@tanstack/react-start": "^1.0.0",
"@tanstack/solid-start": "^1.0.0",
"drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1",
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
"pg": "^8.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"solid-js": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0",
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"@tanstack/react-start": {
"optional": true
},
"@tanstack/solid-start": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"drizzle-orm": {
"optional": true
},
"mongodb": {
"optional": true
},
"mysql2": {
"optional": true
},
"next": {
"optional": true
},
"pg": {
"optional": true
},
"prisma": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vitest": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/better-call": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/better-call/-/better-call-1.4.0.tgz",
"integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.5.0",
"@better-fetch/fetch": "^1.3.1",
"rou3": "^0.9.1",
"set-cookie-parser": "^3.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/better-call/node_modules/@better-auth/utils": {
"version": "0.5.0",
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.5.0.tgz",
"integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
@@ -1254,6 +1553,12 @@
"date-fns": "^3.0.0 || ^4.0.0" "date-fns": "^3.0.0 || ^4.0.0"
} }
}, },
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1379,6 +1684,24 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kysely": {
"version": "0.29.6",
"resolved": "https://registry.npmmirror.com/kysely/-/kysely-0.29.6.tgz",
"integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1736,6 +2059,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/nanostores": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/nanostores/-/nanostores-1.5.4.tgz",
"integrity": "sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1857,12 +2195,24 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3" "@rolldown/binding-win32-x64-msvc": "1.0.3"
} }
}, },
"node_modules/rou3": {
"version": "0.9.2",
"resolved": "https://registry.npmmirror.com/rou3/-/rou3-0.9.2.tgz",
"integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==",
"license": "MIT"
},
"node_modules/seemly": { "node_modules/seemly": {
"version": "0.3.10", "version": "0.3.10",
"resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2151,6 +2501,15 @@
"peerDependencies": { "peerDependencies": {
"vue": "^3.0.11" "vue": "^3.0.11"
} }
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.7.6",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.34", "vue": "^3.5.34",
+10 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NIcon, NSpace } from 'naive-ui' import { NIcon, NSpace, useMessage } from 'naive-ui'
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler' import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -8,6 +8,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
const authStore = useAuthStore() const authStore = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage()
const isMapRoute = computed(() => route.name === 'map') const isMapRoute = computed(() => route.name === 'map')
const headerHidden = ref(false) const headerHidden = ref(false)
@@ -119,10 +120,14 @@ function closeAccountCard() {
} }
async function handleLogout() { async function handleLogout() {
await authStore.logout() try {
closeAccountCard() await authStore.logout()
if (route.meta.requiresAuth) { closeAccountCard()
router.push({ name: 'map' }) if (route.meta.requiresAuth) {
void router.push({ name: 'map' })
}
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
} }
} }
</script> </script>
+1 -4
View File
@@ -27,9 +27,6 @@ export class ApiError extends Error {
interface ApiRequestOptions extends Omit<RequestInit, 'body'> { interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null body?: BodyInit | Record<string, unknown> | null
auth?: boolean auth?: boolean
// Kept for callers that explicitly disable retries. Cookie sessions do not
// use a refresh-and-retry flow, so this option is intentionally ignored.
retry?: boolean
} }
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> { function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
@@ -105,7 +102,7 @@ async function toApiError(response: Response) {
} }
async function request<T>(path: string, options: ApiRequestOptions = {}) { async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry: _retry, ...requestOptions } = options const { body, auth = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers) const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
+33
View File
@@ -0,0 +1,33 @@
import { createAuthClient } from 'better-auth/client'
import { API_URL } from '@/lib/api'
export const authClient = createAuthClient({
baseURL: `${API_URL}/auth`,
fetchOptions: { credentials: 'include' },
})
interface AuthClientFailure {
code?: string
message?: string
status?: number
}
export class AuthClientError extends Error {
code?: string
status?: number
constructor(failure: AuthClientFailure, fallback: string) {
super(failure.code === 'EMAIL_NOT_VERIFIED'
? '邮箱尚未验证,请先查收验证邮件。'
: failure.status === 429
? '操作过于频繁,请稍后重试。'
: fallback)
this.name = 'AuthClientError'
this.code = failure.code
this.status = failure.status
}
}
export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) {
if (result.error) throw new AuthClientError(result.error, fallback)
}
+29 -45
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api' import { assertAuthResult, authClient } from '@/lib/authClient'
import type { BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database' import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@@ -37,62 +38,45 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function login(email: string, password: string) { async function login(email: string, password: string) {
await apiRequest<LoginResponse>('/auth/login', { assertAuthResult(await authClient.signIn.email({ email, password }), '登录失败,请检查邮箱和密码。')
method: 'POST', const session = await authClient.getSession()
auth: false, assertAuthResult(session, '获取登录会话失败,请稍后重试。')
body: { email, password }, if (!session.data) throw new Error('未能建立登录会话,请稍后重试。')
})
await fetchMe() await fetchMe()
} }
async function register(name: string, email: string, password: string) { async function register(name: string, email: string, password: string) {
return await apiRequest<RegisterResponse>('/auth/register', { assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。')
method: 'POST',
auth: false,
body: { name, email, password },
})
} }
async function resendConfirmation(email: string) { async function resendConfirmation(email: string) {
return await apiRequest<ActionResponse>('/auth/resend-confirmation', { assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。')
method: 'POST',
auth: false,
body: { email },
})
} }
async function confirmEmail(token: string) { async function confirmEmail(token: string) {
return await apiRequest<ActionResponse>('/auth/confirm-email', { assertAuthResult(await authClient.$fetch('/verify-email', {
method: 'POST', method: 'GET',
auth: false, query: { token },
body: { token }, }), '确认链接无效或已过期。')
})
} }
async function sendPasswordReset(email: string) { async function sendPasswordReset(email: string) {
return await apiRequest<ActionResponse>('/auth/forgot-password', { assertAuthResult(await authClient.requestPasswordReset({ email }), '重置邮件发送失败,请稍后重试。')
method: 'POST',
auth: false,
body: { email },
})
} }
async function resetPassword(email: string, token: string, newPassword: string) { async function resetPassword(token: string, newPassword: string) {
const response = await apiRequest<ActionResponse>('/auth/reset-password', { assertAuthResult(await authClient.resetPassword({ token, newPassword }), '密码重置失败,请重新申请重置邮件。')
method: 'POST',
auth: false,
body: { email, token, new_password: newPassword },
})
clearAuth() clearAuth()
return response
} }
async function logout() { async function logout() {
try { const result = await authClient.signOut()
await apiRequest('/auth/logout', { method: 'POST', retry: false }) if (result.error?.status === 401) {
} finally {
clearAuth() clearAuth()
return
} }
assertAuthResult(result, '退出登录失败,请稍后重试。')
clearAuth()
} }
async function updateUsername(username: string) { async function updateUsername(username: string) {
@@ -104,14 +88,11 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function updatePassword(currentPassword: string, newPassword: string) { async function updatePassword(currentPassword: string, newPassword: string) {
await apiRequest('/auth/password', { assertAuthResult(await authClient.changePassword({
method: 'PATCH', currentPassword,
body: { newPassword,
current_password: currentPassword, revokeOtherSessions: true,
new_password: newPassword, }), '密码更新失败,请检查当前密码。')
},
})
clearAuth()
} }
async function initialize() { async function initialize() {
@@ -121,7 +102,10 @@ export const useAuthStore = defineStore('auth', () => {
} }
try { try {
await fetchMe() const session = await authClient.getSession()
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
if (session.data) await fetchMe()
else clearAuth()
} catch { } catch {
clearAuth() clearAuth()
} finally { } finally {
-8
View File
@@ -48,14 +48,6 @@ export interface ActionResponse {
message: string message: string
} }
export interface RegisterResponse extends ActionResponse {
email_sent: boolean
}
export interface LoginResponse {
access_token: string
}
export interface CloudCreateResponse extends ActionResponse { export interface CloudCreateResponse extends ActionResponse {
cloud_id: string cloud_id: string
} }
+2 -1
View File
@@ -646,8 +646,9 @@ async function handleLogout() {
accountMenuOpen.value = false accountMenuOpen.value = false
try { try {
await authStore.logout() await authStore.logout()
} finally {
await router.push('/') await router.push('/')
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
} }
} }
+1 -1
View File
@@ -70,7 +70,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
<div class="mb-8"> <div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div> <div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1> <h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</p> <p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 1 小时的重置链接。</p>
</div> </div>
<NForm @submit.prevent="submit"> <NForm @submit.prevent="submit">
<NFormItem label="邮箱"> <NFormItem label="邮箱">
+5 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -12,7 +13,7 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
const password = ref('') const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证')) const needsEmailConfirmation = ref(false)
function getSafeRedirect(value: unknown) { function getSafeRedirect(value: unknown) {
if (typeof value !== 'string' || !value.startsWith('/')) return '/' if (typeof value !== 'string' || !value.startsWith('/')) return '/'
@@ -29,11 +30,13 @@ function getSafeRedirect(value: unknown) {
async function handleLogin() { async function handleLogin() {
error.value = '' error.value = ''
needsEmailConfirmation.value = false
loading.value = true loading.value = true
try { try {
await authStore.login(email.value, password.value) await authStore.login(email.value, password.value)
void router.push(getSafeRedirect(route.query.redirect)) void router.push(getSafeRedirect(route.query.redirect))
} catch (e: unknown) { } catch (e: unknown) {
needsEmailConfirmation.value = e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试' error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
} finally { } finally {
loading.value = false loading.value = false
+7 -12
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { ApiError } from '@/lib/api' import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -15,9 +15,7 @@ const confirmPassword = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const registered = ref(false) const registered = ref(false)
const emailDelivered = ref(true)
const registerConflict = ref(false) const registerConflict = ref(false)
const canResend = computed(() => registerConflict.value)
async function handleRegister() { async function handleRegister() {
error.value = '' error.value = ''
@@ -48,12 +46,11 @@ async function handleRegister() {
loading.value = true loading.value = true
try { try {
const response = await authStore.register(trimmedName, trimmedEmail, password.value) await authStore.register(trimmedName, trimmedEmail, password.value)
email.value = trimmedEmail email.value = trimmedEmail
emailDelivered.value = response.email_sent
registered.value = true registered.value = true
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof ApiError && e.status === 409) { if (e instanceof AuthClientError && (e.status === 409 || e.code === 'USER_ALREADY_EXISTS')) {
registerConflict.value = true registerConflict.value = true
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。' error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
} else { } else {
@@ -93,11 +90,9 @@ async function handleRegister() {
<NCard class="oc-panel-card-xl min-w-0 h-full"> <NCard class="oc-panel-card-xl min-w-0 h-full">
<NResult <NResult
v-if="registered" v-if="registered"
:status="emailDelivered ? 'success' : 'warning'" status="success"
title="确认你的邮箱" title="确认你的邮箱"
:description="emailDelivered description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
? '确认邮件已经发送,请查收并点击链接完成注册。'
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
> >
<template #footer> <template #footer>
<div class="space-y-4"> <div class="space-y-4">
@@ -137,7 +132,7 @@ async function handleRegister() {
<NAlert v-if="error" type="error" class="mb-4"> <NAlert v-if="error" type="error" class="mb-4">
{{ error }} {{ error }}
<div v-if="canResend" class="mt-2"> <div v-if="registerConflict" class="mt-2">
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink> <RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
</div> </div>
</NAlert> </NAlert>
+5 -4
View File
@@ -2,6 +2,7 @@
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -11,8 +12,8 @@ const email = typeof route.query.email === 'string' ? route.query.email : ''
const token = typeof route.query.token === 'string' ? route.query.token : '' const token = typeof route.query.token === 'string' ? route.query.token : ''
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const error = ref(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '') const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
const invalid = ref(!email || !token) const invalid = ref(!token)
const success = ref(false) const success = ref(false)
const loading = ref(false) const loading = ref(false)
const countdown = ref(5) const countdown = ref(5)
@@ -46,12 +47,12 @@ async function submit() {
} }
loading.value = true loading.value = true
try { try {
await authStore.resetPassword(email, token, password.value) await authStore.resetPassword(token, password.value)
success.value = true success.value = true
startCountdown() startCountdown()
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试' error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
if (error.value.includes('无效') || error.value.includes('过期')) invalid.value = true if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN')) invalid.value = true
} finally { } finally {
loading.value = false loading.value = false
} }
+3 -4
View File
@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter()
const message = useMessage() const message = useMessage()
const usernameDraft = ref('') const usernameDraft = ref('')
@@ -75,8 +74,8 @@ async function savePassword() {
currentPassword.value = '' currentPassword.value = ''
newPassword.value = '' newPassword.value = ''
confirmNewPassword.value = '' confirmNewPassword.value = ''
message.success('密码已更新,请重新登录') success.value = '密码已更新。其他设备上的会话已退出。'
await router.push('/login') message.success('密码已更新')
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '密码更新失败' error.value = e instanceof Error ? e.message : '密码更新失败'
} finally { } finally {