diff --git a/.gitignore b/.gitignore index 847230601e..2925b43649 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,12 @@ CLAUDE.local.md # MCP tools .serena/** +# Migration scripts data +scripts/clerk-to-betterauth/test/*.csv +scripts/clerk-to-betterauth/test/*.json +scripts/clerk-to-betterauth/prod/*.csv +scripts/clerk-to-betterauth/prod/*.json + # Misc ./packages/lobe-ui *.ppt* diff --git a/docs/self-hosting/advanced/auth.mdx b/docs/self-hosting/advanced/auth.mdx index a8e4a07e7b..77315df4f0 100644 --- a/docs/self-hosting/advanced/auth.mdx +++ b/docs/self-hosting/advanced/auth.mdx @@ -60,39 +60,39 @@ To enable Better Auth in LobeChat, set the following environment variables: Click on a provider below for detailed configuration guides: - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + ## Callback URL Format @@ -104,19 +104,44 @@ When configuring OAuth providers, use the following callback URL format: ## Email Service Configuration -Used by email verification, password reset, and magic-link delivery. Choose a provider, then fill the matching variables: +Used by email verification, password reset, and magic-link delivery. Two providers are supported: -| Environment Variable | Type | Description | -| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification before sign in (off by default) | -| `EMAIL_SERVICE_PROVIDER` | Optional | Email provider selector: `nodemailer` (default, SMTP) or `resend` | -| `SMTP_HOST` | Required | SMTP server hostname (e.g., `smtp.gmail.com`). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` | -| `SMTP_PORT` | Required | SMTP server port (usually `587` for TLS, `465` for SSL). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` | -| `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` | -| `SMTP_USER` | Required | SMTP auth username. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` | -| `SMTP_PASS` | Required | SMTP auth password. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` | -| `RESEND_API_KEY` | Required | Resend API key. Required when `EMAIL_SERVICE_PROVIDER=resend` | -| `RESEND_FROM` | Recommended | Default sender address (e.g., `noreply@your-verified-domain.com`). Must be a domain verified in Resend. Used when `EMAIL_SERVICE_PROVIDER=resend` | +### Option 1: Nodemailer (SMTP) + +Send emails via SMTP protocol, suitable for users with existing email services. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/). + +| Environment Variable | Type | Description | Example | +| ------------------------ | -------- | ------------------------------------------------------- | ------------------- | +| `EMAIL_SERVICE_PROVIDER` | Optional | Set to `nodemailer` (default) | `nodemailer` | +| `SMTP_HOST` | Required | SMTP server hostname | `smtp.gmail.com` | +| `SMTP_PORT` | Required | SMTP server port (`587` for TLS, `465` for SSL) | `587` | +| `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587) | `false` | +| `SMTP_USER` | Required | SMTP auth username | `user@gmail.com` | +| `SMTP_PASS` | Required | SMTP auth password | `your-app-password` | + + + When using Gmail, you must use an App Password instead of your account password. Generate one at [Google App Passwords](https://myaccount.google.com/apppasswords). + + +### Option 2: Resend + +[Resend](https://resend.com/) is a modern email API service with simple setup, recommended for new users. + +| Environment Variable | Type | Description | Example | +| ------------------------ | ----------- | ---------------------------------------- | --------------------------- | +| `EMAIL_SERVICE_PROVIDER` | Required | Set to `resend` | `resend` | +| `RESEND_API_KEY` | Required | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` | +| `RESEND_FROM` | Recommended | Sender address, must be a verified domain| `noreply@your-domain.com` | + + + Before using Resend, you need to [verify your sending domain](https://resend.com/docs/dashboard/domains/introduction), otherwise emails can only be sent to your own address. + + +### Common Configuration + +| Environment Variable | Type | Description | Example | +| ------------------------- | -------- | -------------------------------------------------------- | ------- | +| `AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification (off by default)| `1` | ## Magic Link (Passwordless) Login diff --git a/docs/self-hosting/advanced/auth.zh-CN.mdx b/docs/self-hosting/advanced/auth.zh-CN.mdx index 5cd1d8205a..9ca0867c0f 100644 --- a/docs/self-hosting/advanced/auth.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth.zh-CN.mdx @@ -57,39 +57,39 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解 点击下方提供商查看详细配置指南: - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + ## 回调 URL 格式 @@ -101,19 +101,44 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解 ## 邮件服务配置 -用于邮箱验证、密码重置和魔法链接发送。先选择邮件服务,再填对应变量: +用于邮箱验证、密码重置和魔法链接发送。支持两种邮件服务: -| 环境变量 | 类型 | 描述 | -| ------------------------- | -- | ----------------------------------------------------------------------------------------- | -| `AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱(默认关闭) | -| `EMAIL_SERVICE_PROVIDER` | 可选 | 邮件服务选择:`nodemailer`(默认,SMTP)或 `resend` | -| `SMTP_HOST` | 必选 | SMTP 服务器主机名(如 `smtp.gmail.com`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 | -| `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL 为 `465`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 | -| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 | -| `SMTP_USER` | 必选 | SMTP 认证用户名,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 | -| `SMTP_PASS` | 必选 | SMTP 认证密码,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 | -| `RESEND_API_KEY` | 必选 | Resend API Key,`EMAIL_SERVICE_PROVIDER=resend` 时必填 | -| `RESEND_FROM` | 推荐 | 默认发件人地址(如 `noreply@已验证域名`),需为 Resend 已验证域名下的邮箱,`EMAIL_SERVICE_PROVIDER=resend` 时使用 | +### 方式一:Nodemailer(SMTP) + +使用 SMTP 协议发送邮件,适合已有邮箱服务的用户。参考 [Nodemailer SMTP 文档](https://nodemailer.com/smtp/)。 + +| 环境变量 | 类型 | 描述 | 示例 | +| ------------------------- | -- | ----------------------------------------------- | ------------------ | +| `EMAIL_SERVICE_PROVIDER` | 可选 | 设置为 `nodemailer`(默认值) | `nodemailer` | +| `SMTP_HOST` | 必选 | SMTP 服务器主机名 | `smtp.gmail.com` | +| `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL 为 `465`) | `587` | +| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587) | `false` | +| `SMTP_USER` | 必选 | SMTP 认证用户名 | `user@gmail.com` | +| `SMTP_PASS` | 必选 | SMTP 认证密码 | `your-app-password`| + + + 使用 Gmail 时,需使用应用专用密码而非账户密码。前往 [Google 应用专用密码](https://myaccount.google.com/apppasswords) 生成。 + + +### 方式二:Resend + +[Resend](https://resend.com/) 是一个现代邮件 API 服务,配置简单,推荐新用户使用。 + +| 环境变量 | 类型 | 描述 | 示例 | +| ------------------------- | -- | ---------------------------------- | --------------------------- | +| `EMAIL_SERVICE_PROVIDER` | 必选 | 设置为 `resend` | `resend` | +| `RESEND_API_KEY` | 必选 | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` | +| `RESEND_FROM` | 推荐 | 发件人地址,需为 Resend 已验证域名下的邮箱 | `noreply@your-domain.com` | + + + 使用 Resend 前需先 [验证发件域名](https://resend.com/docs/dashboard/domains/introduction),否则只能发送到自己的邮箱。 + + +### 通用配置 + +| 环境变量 | 类型 | 描述 | 示例 | +| ------------------------- | -- | ---------------------------- | -- | +| `AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱(默认关闭) | `1`| ## 魔法链接(免密)登录 diff --git a/docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx b/docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx new file mode 100644 index 0000000000..3bfdbd575d --- /dev/null +++ b/docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx @@ -0,0 +1,366 @@ +--- +title: Migrating from Clerk to Better Auth +description: >- + Guide for migrating your LobeChat deployment from Clerk authentication to + Better Auth, including simple and full migration options. +tags: + - Authentication Service + - Better Auth + - Clerk + - Migration +--- + +# Migrating from Clerk to Better Auth + +This guide helps you migrate your existing Clerk-based LobeChat deployment to Better Auth. + + + Better Auth is the recommended authentication solution for LobeChat. It offers simpler configuration, more SSO providers, and better self-hosting support. + + + + **Important Notice**: + + - **Always backup your database first!** For Neon users, create a backup via [Fork Branch](https://neon.tech/docs/manage/branches#create-a-branch) + - LobeChat is not responsible for any data loss or issues that may occur during the migration process + - This guide is intended for users with development experience; not recommended for users without technical background + - If you have any questions, feel free to ask in our [Discord](https://discord.com/invite/AYFPHvv2jT) community + + +## Choose Your Migration Path + +| Method | Best For | User Impact | Data Preserved | +| ------------------------------------- | -------------------------------- | --------------------- | ------------------------------ | +| [Simple Migration](#simple-migration) | Small deployments (\< 100 users) | Users reset passwords | Chat history, settings | +| [Full Migration](#full-migration) | Large deployments | Seamless for users | Everything including passwords | + +## Simple Migration + +For small self-hosted deployments, the simplest approach is to let users reset their passwords. + + + **Limitation**: This method loses SSO connection data. Use [Full Migration](#full-migration) to preserve SSO connections. + + **Example scenario**: If your previous account had two SSO accounts linked: + + - Primary email (Google): `mail1@google.com` + - Secondary email (Microsoft): `mail2@outlook.com` + + After migrating and resetting password with `mail1@google.com`, logging in with `mail2@outlook.com` will create a **new user** instead of linking to your existing account. + + +![Profile Page - Linked Accounts](https://hub-apac-1.lobeobjects.space/docs/d9b41a1607d49319fd670e88529199cf.png) + +### Steps + +1. **Configure Email Service** + + Set up email service for password reset functionality. See [Email Service Configuration](/docs/self-hosting/advanced/auth#email-service-configuration). + +2. **Update Environment Variables** + + Remove Clerk variables and add Better Auth variables: + + ```bash + # Remove these + # NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx + # CLERK_SECRET_KEY=xxx + + # Add these + AUTH_SECRET=your-secret-key # openssl rand -base64 32 + + # Optional: Enable Google SSO (example) + AUTH_SSO_PROVIDERS=google + AUTH_GOOGLE_ID=your-google-client-id + AUTH_GOOGLE_SECRET=your-google-client-secret + ``` + + + See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup. + + +3. **Redeploy LobeChat** + + Deploy the new version with Better Auth enabled. + +4. **Notify Users** + + Inform users to follow these steps to log in (chat history and settings will be preserved): + + 1. Visit the login page (e.g., `https://your-domain.com/signin`) + 2. Enter their previous email address and press Enter + + If Magic Link is enabled: The system will automatically send a login link email. + Users can click the link to log in directly. + + If Magic Link is not enabled: The page will display a hint message. Users can either: + + - Log in using their previously linked social account (e.g., Google, GitHub) + - Click "Set Password" to receive an email and set a new password + + ![LobeChat Login Page - Social Login Hint](https://hub-apac-1.lobeobjects.space/docs/8d0563701fcd17ad7252a72b1746dd42.png) + + 3. (Optional) After logging in, users can manage their account in the Profile page: + - Linked Accounts: Link additional social accounts + - Password: Set or update password at any time + + + This method is quick and requires minimal setup. + Users can log in via Magic Link, social accounts, or by setting a new password. + All data remains intact. + Users can manage their password and linked accounts anytime in the [Profile page](/settings/profile). + + +## Full Migration + +For larger deployments or when you need to preserve user passwords and SSO connections, use the migration scripts. + + + **Important Notice**: + + - Migration scripts must be **run locally after cloning the repository**, not in the deployment environment + - Due to the high-risk nature of user data migration, **we do not provide automatic migration during deployment** + - Always verify in a test environment before operating on production database + + + + **Before Migration**: + + - Use a [Neon Fork Branch](https://neon.tech/docs/manage/branches#create-a-branch) to create a test database + - Use Clerk Development environment API keys + - Verify in test mode first, then switch to prod mode after confirming success + + +### Prerequisites + +**Environment Requirements:** + +- Node.js 18+ +- Git (for cloning the repository) +- pnpm (for installing dependencies) + +**Preparation:** + +1. Clone the LobeChat repository and install dependencies: + + ```bash + git clone https://github.com/lobehub/lobe-chat.git + cd lobe-chat + pnpm install + ``` + +2. Prepare the following information: + - Access to Clerk Dashboard (for CSV export) + - Clerk API Secret Key (for API export) + - Database connection string + +3. Ensure database schema is up to date + + + If you've been on an older version (e.g., 1.x) for a while, your database schema may be outdated. Run this in the cloned repository: + + ```bash + DATABASE_URL=your-database-url pnpm db:migrate + ``` + + +### Step 1: Configure Migration Script Environment Variables + +Create a `.env` file in the project root (the script will automatically load it) with all environment variables: + +```bash +# ============================================ +# Migration mode: test or prod +# Recommended: Start with test mode to verify on a test database, +# then switch to prod after confirming everything works +# ============================================ +CLERK_TO_BETTERAUTH_MODE=test + +# ============================================ +# Database connection (uses corresponding variable based on mode) +# TEST_ prefix for test environment, PROD_ prefix for production +# ============================================ +TEST_CLERK_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@test-host:5432/testdb +PROD_CLERK_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@prod-host:5432/proddb + +# ============================================ +# Clerk API keys (for exporting user data via API) +# Get from Clerk Dashboard: Configure → Developers → API Keys +# ============================================ +TEST_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY=sk_test_xxx +PROD_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY=sk_live_xxx + +# ============================================ +# Database driver (optional) +# neon: Neon serverless driver (default) +# node: node-postgres driver +# ============================================ +CLERK_TO_BETTERAUTH_DATABASE_DRIVER=neon + +# ============================================ +# Dry Run mode (optional) +# Set to 1 to only print logs without modifying the database +# Recommended: Enable for first run, disable after verification +# ============================================ +CLERK_TO_BETTERAUTH_DRY_RUN=1 +``` + +### Step 2: Export Clerk Data + +#### Stop New User Registration + +Before exporting data, disable new user registration to ensure data consistency during migration: + +1. Go to [Clerk Dashboard](https://dashboard.clerk.com) → Configure → Restrictions +2. Enable "Restricted" mode + +#### Export CSV from Clerk Dashboard + +1. Go to [Clerk Dashboard](https://dashboard.clerk.com) → Configure → Settings → User exports +2. Click "Export users" to download the CSV file +3. Save it to `scripts/clerk-to-betterauth/test/clerk_exported_users.csv` + +See [Clerk documentation](https://clerk.com/docs/guides/development/migrating/overview#export-your-users-data-from-the-clerk-dashboard) for details. + +![Clerk Dashboard - Export Users CSV](https://hub-apac-1.lobeobjects.space/docs/6523e1805a69d3ece3804e2bcd5d4552.png) + +#### Export JSON via API + +```bash +# Run export script (automatically selects key and output path based on CLERK_TO_BETTERAUTH_MODE) +npx tsx scripts/clerk-to-betterauth/export-clerk-users-with-api.ts +``` + +This automatically creates `scripts/clerk-to-betterauth/test/clerk_users.json` with additional user data. + +### Step 3: Dry-Run Verification (Test Environment) + +```bash +# Run migration (CLERK_TO_BETTERAUTH_DRY_RUN=1, only logs without modifying database) +npx tsx scripts/clerk-to-betterauth/index.ts +``` + +Review the output logs, confirm no issues, then proceed to the next step. + +### Step 4: Execute Migration and Verify (Test Environment) + +Update `.env` to set `CLERK_TO_BETTERAUTH_DRY_RUN` to `0`, then execute: + +```bash +# Execute migration +npx tsx scripts/clerk-to-betterauth/index.ts + +# Verify the migration +npx tsx scripts/clerk-to-betterauth/verify.ts +``` + +After verifying the test environment migration is successful, proceed to the next step. + +### Step 5: Dry-Run Verification (Production Environment) + +1. Copy the CSV file to prod directory: `scripts/clerk-to-betterauth/prod/clerk_exported_users.csv` +2. Update `.env` file: + - Change `CLERK_TO_BETTERAUTH_MODE` to `prod` + - Change `CLERK_TO_BETTERAUTH_DRY_RUN` back to `1` +3. Run the scripts: + +```bash +# Export production user data +npx tsx scripts/clerk-to-betterauth/export-clerk-users-with-api.ts + +# Run migration (dry-run mode to verify) +npx tsx scripts/clerk-to-betterauth/index.ts +``` + +Review the output logs, confirm no issues, then proceed to the next step. + +### Step 6: Execute Migration and Verify (Production Environment) + +Update `.env` to set `CLERK_TO_BETTERAUTH_DRY_RUN` to `0`, then execute: + +```bash +# Execute migration +npx tsx scripts/clerk-to-betterauth/index.ts + +# Verify the migration +npx tsx scripts/clerk-to-betterauth/verify.ts +``` + +### Step 7: Configure Better Auth and Redeploy + +After migration is complete, configure Better Auth environment variables and redeploy: + +1. **Remove Clerk environment variables**: + + ```bash + # Remove these + # NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx + # CLERK_SECRET_KEY=xxx + ``` + +2. **Add Better Auth environment variables**: + + ```bash + # Required + AUTH_SECRET=your-secret-key # openssl rand -base64 32 + + # Optional: Configure SSO providers (example) + AUTH_SSO_PROVIDERS=google,github + AUTH_GOOGLE_ID=your-google-client-id + AUTH_GOOGLE_SECRET=your-google-client-secret + AUTH_GITHUB_ID=your-github-client-id + AUTH_GITHUB_SECRET=your-github-client-secret + + # Optional: Configure email service (for password reset, email verification, etc.) + # See Authentication Service Configuration documentation for details + ``` + +3. **Redeploy LobeChat** + + + For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth), including all supported SSO providers and email service configuration. + + +## What Gets Migrated + +| Data | Simple Migration | Full Migration | +| -------------------------------------- | ---------------------- | -------------- | +| User accounts | ✅ (via password reset) | ✅ | +| Password hashes | ❌ | ✅ | +| SSO connections (Google, GitHub, etc.) | ❌ | ✅ | +| Two-factor authentication | ❌ | ✅ | +| Chat history | ✅ | ✅ | +| User settings | ✅ | ✅ | + +## Troubleshooting + +### Users can't log in after migration + +- Ensure email service is configured for password reset +- Check that `AUTH_SECRET` is set correctly +- Verify database connection is working + +### SSO users can't connect + +- For simple migration: Users need to link their SSO accounts again after resetting password +- For full migration: Verify the SSO provider is configured in `AUTH_SSO_PROVIDERS` + +### Migration script fails + +- Check database connection string +- Ensure CSV and JSON files are in the correct location +- Review script logs for specific errors + +### column "xxx" of relation "users" does not exist + +This error occurs because the database schema is outdated. Run `pnpm db:migrate` to update the database structure before running the migration script. + +## Related Reading + + + + + + + + diff --git a/docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx b/docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx new file mode 100644 index 0000000000..dcadc04bcd --- /dev/null +++ b/docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx @@ -0,0 +1,360 @@ +--- +title: 从 Clerk 迁移到 Better Auth +description: 将 LobeChat 部署从 Clerk 身份验证迁移到 Better Auth 的指南,包括简单迁移和完整迁移选项。 +tags: + - 身份验证服务 + - Better Auth + - Clerk + - 迁移 +--- + +# 从 Clerk 迁移到 Better Auth + +本指南帮助您将现有的基于 Clerk 的 LobeChat 部署迁移到 Better Auth。 + + + Better Auth 是 LobeChat 推荐的身份验证解决方案。它提供更简单的配置、更多的 SSO 提供商支持,以及更好的自托管体验。 + + + + **重要提醒**: + + - **务必先备份数据库**!如使用 Neon,可通过 [Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建备份 + - 迁移过程中可能出现的任何数据丢失或问题,LobeChat 概不负责 + - 本指南适合有一定开发背景的用户,不建议无技术经验的用户自行操作 + - 如有任何疑问,欢迎到 [Discord](https://discord.com/invite/AYFPHvv2jT) 社区提问 + + +## 选择迁移方式 + +| 方式 | 适用场景 | 用户影响 | 数据保留 | +| ------------- | --------------- | ------- | -------- | +| [简单迁移](#简单迁移) | 小型部署(\< 100 用户) | 用户需重置密码 | 聊天记录、设置 | +| [完整迁移](#完整迁移) | 大型部署 | 对用户无感知 | 全部数据包括密码 | + +## 简单迁移 + +对于小型自托管部署,最简单的方法是让用户重置密码。 + + + **限制**:此方法会丢失 SSO 连接数据。如需保留 SSO 连接,请使用 [完整迁移](#完整迁移)。 + + **示例场景**:假设你之前的账户绑定了两个 SSO 账户: + + - 主邮箱(Google):`mail1@google.com` + - 副邮箱(Microsoft):`mail2@outlook.com` + + 迁移后使用 `mail1@google.com` 重置密码,之后再用 `mail2@outlook.com` 登录将会**创建新用户**,而非关联到原有账户。 + + +![个人资料页 - 关联账号信息](https://hub-apac-1.lobeobjects.space/docs/43dfa498b82a58c9f99e805e88ea711a.png) + +### 步骤 + +1. **配置邮件服务** + + 设置邮件服务以支持密码重置功能。参阅 [邮件服务配置](/zh/docs/self-hosting/advanced/auth#邮件服务配置)。 + +2. **更新环境变量** + + 移除 Clerk 变量并添加 Better Auth 变量: + + ```bash + # 移除这些 + # NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx + # CLERK_SECRET_KEY=xxx + + # 添加这些 + AUTH_SECRET=your-secret-key # openssl rand -base64 32 + + # 可选:启用 Google SSO(示例) + AUTH_SSO_PROVIDERS=google + AUTH_GOOGLE_ID=your-google-client-id + AUTH_GOOGLE_SECRET=your-google-client-secret + ``` + + + 查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。 + + +3. **重新部署 LobeChat** + + 部署启用 Better Auth 的新版本。 + +4. **通知用户** + + 告知用户按以下步骤登录(聊天记录和设置将被保留): + + 1. 访问登录页(如 `https://your-domain.com/signin`) + 2. 输入之前使用的邮箱,回车 + + 如果启用了 Magic Link:系统会自动发送登录链接邮件,用户点击邮件中的链接即可直接登录。 + + 如果未启用 Magic Link:页面会显示提示信息,用户可以选择: + + - 使用之前关联的社交账号(如 Google、GitHub)登录 + - 点击「设置密码」链接,通过邮件设置新密码后登录 + + ![LobeChat 登录页面 - 社交账号登录提示](https://hub-apac-1.lobeobjects.space/docs/8d0563701fcd17ad7252a72b1746dd42.png) + + 3. (可选)登录后可在个人资料页进行以下操作: + - 已关联账号:手动关联其他社交账号 + - 密码:随时设置或更新密码 + + + 这种方法快速且配置简单。用户可通过 Magic Link、社交账号或设置新密码登录,所有数据完整保留。 + 登录后可随时在 [个人资料页](/settings/profile) 管理密码和关联账号。 + + +## 完整迁移 + +对于大型部署或需要保留用户密码和 SSO 连接的情况,请使用迁移脚本。 + + + **重要说明**: + + - 迁移脚本需要 **clone 仓库后在本地运行**,不是在部署环境中执行 + - 由于迁移涉及用户数据,风险较高,**官方不提供部署时自动迁移功能** + - 请务必在测试环境验证后再操作生产数据库 + + + + **迁移前准备**: + + - 使用 [Neon Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建测试数据库 + - 使用 Clerk Development 环境的 API 密钥 + - 先在 test 模式下验证,确认成功后再切换到 prod 模式 + + +### 前置条件 + +**环境要求:** + +- Node.js 18+ +- Git(用于 clone 仓库) +- pnpm(用于安装依赖) + +**准备工作:** + +1. Clone LobeChat 仓库并安装依赖: + + ```bash + git clone https://github.com/lobehub/lobe-chat.git + cd lobe-chat + pnpm install + ``` + +2. 准备以下信息: + - Clerk 控制台访问权限(用于 CSV 导出) + - Clerk API 密钥(用于 API 导出) + - 数据库连接字符串 + +3. 确保数据库 schema 为最新版本 + + + 如果你长期停留在旧版本(如 1.x),数据库 schema 可能不是最新的。请在 clone 的仓库中运行: + + ```bash + DATABASE_URL=your-database-url pnpm db:migrate + ``` + + +### 步骤 1:配置迁移脚本环境变量 + +在项目根目录创建 `.env` 文件(脚本会自动加载),配置所有环境变量: + +```bash +# ============================================ +# 迁移模式:test 或 prod +# 建议先用 test 模式在测试数据库验证,确认无误后再切换到 prod +# ============================================ +CLERK_TO_BETTERAUTH_MODE=test + +# ============================================ +# 数据库连接(根据模式使用对应的环境变量) +# TEST_ 前缀用于测试环境,PROD_ 前缀用于生产环境 +# ============================================ +TEST_CLERK_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@test-host:5432/testdb +PROD_CLERK_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@prod-host:5432/proddb + +# ============================================ +# Clerk API 密钥(用于通过 API 导出用户数据) +# 从 Clerk 控制台获取:Configure → Developers → API Keys +# ============================================ +TEST_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY=sk_test_xxx +PROD_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY=sk_live_xxx + +# ============================================ +# 数据库驱动(可选) +# neon: Neon serverless 驱动(默认) +# node: node-postgres 驱动 +# ============================================ +CLERK_TO_BETTERAUTH_DATABASE_DRIVER=neon + +# ============================================ +# Dry Run 模式(可选) +# 设为 1 时只打印日志,不实际修改数据库 +# 建议首次运行时启用,验证无误后再关闭 +# ============================================ +CLERK_TO_BETTERAUTH_DRY_RUN=1 +``` + +### 步骤 2:导出 Clerk 数据 + +#### 停止新用户注册 + +在导出数据前,先禁止新用户注册,确保迁移期间数据不变: + +1. 前往 [Clerk 控制台](https://dashboard.clerk.com) → Configure → Restrictions +2. 启用「Restricted」模式 + +#### 从 Clerk 控制台导出 CSV + +1. 前往 [Clerk 控制台](https://dashboard.clerk.com) → Configure → Settings → User exports +2. 点击「Export users」下载 CSV 文件 +3. 保存到 `scripts/clerk-to-betterauth/test/clerk_exported_users.csv` + +详见 [Clerk 官方文档](https://clerk.com/docs/guides/development/migrating/overview#export-your-users-data-from-the-clerk-dashboard)。 + +![Clerk Dashboard - 导出用户 CSV](https://hub-apac-1.lobeobjects.space/docs/6523e1805a69d3ece3804e2bcd5d4552.png) + +#### 通过 API 导出 JSON + +```bash +# 运行导出脚本(会根据 CLERK_TO_BETTERAUTH_MODE 自动选择密钥和输出路径) +npx tsx scripts/clerk-to-betterauth/export-clerk-users-with-api.ts +``` + +这将自动创建 `scripts/clerk-to-betterauth/test/clerk_users.json`,包含额外的用户数据。 + +### 步骤 3:Dry-Run 验证(测试环境) + +```bash +# 运行迁移(CLERK_TO_BETTERAUTH_DRY_RUN=1,只打印日志不修改数据库) +npx tsx scripts/clerk-to-betterauth/index.ts +``` + +检查输出日志,确认无异常后继续下一步。 + +### 步骤 4:执行迁移并验证(测试环境) + +修改 `.env` 将 `CLERK_TO_BETTERAUTH_DRY_RUN` 改为 `0`,然后执行: + +```bash +# 执行迁移 +npx tsx scripts/clerk-to-betterauth/index.ts + +# 验证迁移结果 +npx tsx scripts/clerk-to-betterauth/verify.ts +``` + +验证测试环境迁移结果无误后,继续下一步。 + +### 步骤 5:Dry-Run 验证(生产环境) + +1. 将 CSV 文件复制到 prod 目录:`scripts/clerk-to-betterauth/prod/clerk_exported_users.csv` +2. 修改 `.env` 文件: + - 将 `CLERK_TO_BETTERAUTH_MODE` 改为 `prod` + - 将 `CLERK_TO_BETTERAUTH_DRY_RUN` 改回 `1` +3. 运行脚本: + +```bash +# 导出生产环境用户数据 +npx tsx scripts/clerk-to-betterauth/export-clerk-users-with-api.ts + +# 运行迁移(dry-run 模式验证) +npx tsx scripts/clerk-to-betterauth/index.ts +``` + +检查输出日志,确认无异常后继续下一步。 + +### 步骤 6:执行迁移并验证(生产环境) + +修改 `.env` 将 `CLERK_TO_BETTERAUTH_DRY_RUN` 改为 `0`,然后执行: + +```bash +# 执行迁移 +npx tsx scripts/clerk-to-betterauth/index.ts + +# 验证迁移结果 +npx tsx scripts/clerk-to-betterauth/verify.ts +``` + +### 步骤 7:配置 Better Auth 并重新部署 + +迁移完成后,需要配置 Better Auth 环境变量并重新部署: + +1. **移除 Clerk 环境变量**: + + ```bash + # 移除这些 + # NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx + # CLERK_SECRET_KEY=xxx + ``` + +2. **添加 Better Auth 环境变量**: + + ```bash + # 必需 + AUTH_SECRET=your-secret-key # openssl rand -base64 32 + + # 可选:配置 SSO 提供商(示例) + AUTH_SSO_PROVIDERS=google,github + AUTH_GOOGLE_ID=your-google-client-id + AUTH_GOOGLE_SECRET=your-google-client-secret + AUTH_GITHUB_ID=your-github-client-id + AUTH_GITHUB_SECRET=your-github-client-secret + + # 可选:配置邮件服务(用于密码重置、邮箱验证等) + # 参阅身份验证服务配置文档了解详情 + ``` + +3. **重新部署 LobeChat** + + + 完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。 + + +## 迁移内容对比 + +| 数据 | 简单迁移 | 完整迁移 | +| ----------------------- | --------- | ---- | +| 用户账户 | ✅(通过密码重置) | ✅ | +| 密码哈希 | ❌ | ✅ | +| SSO 连接(Google、GitHub 等) | ❌ | ✅ | +| 双因素认证 | ❌ | ✅ | +| 聊天记录 | ✅ | ✅ | +| 用户设置 | ✅ | ✅ | + +## 常见问题 + +### 迁移后用户无法登录 + +- 确保邮件服务已配置用于密码重置 +- 检查 `AUTH_SECRET` 是否正确设置 +- 验证数据库连接是否正常 + +### SSO 用户无法连接 + +- 简单迁移:用户需要在重置密码后重新关联 SSO 账户 +- 完整迁移:验证 SSO 提供商已在 `AUTH_SSO_PROVIDERS` 中配置 + +### 迁移脚本失败 + +- 检查数据库连接字符串 +- 确保 CSV 和 JSON 文件位于正确位置 +- 查看脚本日志了解具体错误 + +### column "xxx" of relation "users" does not exist + +这是因为数据库 schema 未更新。请先运行 `pnpm db:migrate` 更新数据库结构,然后再执行迁移脚本。 + +## 相关阅读 + + + + + + + + diff --git a/docs/self-hosting/advanced/auth/legacy.mdx b/docs/self-hosting/advanced/auth/legacy.mdx index de17e26f2c..5692f51112 100644 --- a/docs/self-hosting/advanced/auth/legacy.mdx +++ b/docs/self-hosting/advanced/auth/legacy.mdx @@ -30,6 +30,10 @@ By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CL For detailed Clerk configuration, see [Clerk Configuration Guide](/docs/self-hosting/advanced/auth/clerk). + + To migrate from Clerk to Better Auth, see the [Clerk Migration Guide](/docs/self-hosting/advanced/auth/clerk-to-betterauth). + + ## Next Auth Before using NextAuth, please set the following variables in LobeChat's environment variables: diff --git a/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx b/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx index 6d96753980..f01a1c7890 100644 --- a/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx @@ -28,6 +28,10 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供安全、便捷的 详细的 Clerk 配置请参阅 [Clerk 配置指南](/zh/docs/self-hosting/advanced/auth/clerk)。 + + 如需从 Clerk 迁移到 Better Auth,请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/advanced/auth/clerk-to-betterauth)。 + + ## Next Auth 在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量: diff --git a/docs/self-hosting/advanced/auth/better-auth/apple.mdx b/docs/self-hosting/advanced/auth/providers/apple.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/apple.mdx rename to docs/self-hosting/advanced/auth/providers/apple.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/apple.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/apple.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/apple.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/apple.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/auth0.mdx b/docs/self-hosting/advanced/auth/providers/auth0.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/auth0.mdx rename to docs/self-hosting/advanced/auth/providers/auth0.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/auth0.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/auth0.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/auth0.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/auth0.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/authelia.mdx b/docs/self-hosting/advanced/auth/providers/authelia.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/authelia.mdx rename to docs/self-hosting/advanced/auth/providers/authelia.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/authelia.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/authelia.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/authelia.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/authelia.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/authentik.mdx b/docs/self-hosting/advanced/auth/providers/authentik.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/authentik.mdx rename to docs/self-hosting/advanced/auth/providers/authentik.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/authentik.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/authentik.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/authentik.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/authentik.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/casdoor.mdx b/docs/self-hosting/advanced/auth/providers/casdoor.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/casdoor.mdx rename to docs/self-hosting/advanced/auth/providers/casdoor.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/casdoor.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/casdoor.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/casdoor.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust.mdx b/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust.mdx rename to docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/cognito.mdx b/docs/self-hosting/advanced/auth/providers/cognito.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/cognito.mdx rename to docs/self-hosting/advanced/auth/providers/cognito.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/cognito.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/cognito.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/cognito.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/cognito.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/feishu.mdx b/docs/self-hosting/advanced/auth/providers/feishu.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/feishu.mdx rename to docs/self-hosting/advanced/auth/providers/feishu.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/feishu.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/feishu.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/feishu.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/feishu.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/generic-oidc.mdx b/docs/self-hosting/advanced/auth/providers/generic-oidc.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/generic-oidc.mdx rename to docs/self-hosting/advanced/auth/providers/generic-oidc.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/generic-oidc.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/generic-oidc.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/generic-oidc.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/generic-oidc.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/github.mdx b/docs/self-hosting/advanced/auth/providers/github.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/github.mdx rename to docs/self-hosting/advanced/auth/providers/github.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/github.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/github.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/github.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/github.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/google.mdx b/docs/self-hosting/advanced/auth/providers/google.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/google.mdx rename to docs/self-hosting/advanced/auth/providers/google.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/google.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/google.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/google.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/google.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/keycloak.mdx b/docs/self-hosting/advanced/auth/providers/keycloak.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/keycloak.mdx rename to docs/self-hosting/advanced/auth/providers/keycloak.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/keycloak.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/keycloak.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/keycloak.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/keycloak.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/logto.mdx b/docs/self-hosting/advanced/auth/providers/logto.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/logto.mdx rename to docs/self-hosting/advanced/auth/providers/logto.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/logto.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/logto.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/logto.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/microsoft.mdx b/docs/self-hosting/advanced/auth/providers/microsoft.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/microsoft.mdx rename to docs/self-hosting/advanced/auth/providers/microsoft.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/microsoft.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/microsoft.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/microsoft.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/microsoft.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/okta.mdx b/docs/self-hosting/advanced/auth/providers/okta.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/okta.mdx rename to docs/self-hosting/advanced/auth/providers/okta.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/okta.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/okta.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/okta.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/okta.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/wechat.mdx b/docs/self-hosting/advanced/auth/providers/wechat.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/wechat.mdx rename to docs/self-hosting/advanced/auth/providers/wechat.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/wechat.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/wechat.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/wechat.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/wechat.zh-CN.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/zitadel.mdx b/docs/self-hosting/advanced/auth/providers/zitadel.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/zitadel.mdx rename to docs/self-hosting/advanced/auth/providers/zitadel.mdx diff --git a/docs/self-hosting/advanced/auth/better-auth/zitadel.zh-CN.mdx b/docs/self-hosting/advanced/auth/providers/zitadel.zh-CN.mdx similarity index 100% rename from docs/self-hosting/advanced/auth/better-auth/zitadel.zh-CN.mdx rename to docs/self-hosting/advanced/auth/providers/zitadel.zh-CN.mdx diff --git a/locales/en-US/auth.json b/locales/en-US/auth.json index 3ce9015639..e3c8fae059 100644 --- a/locales/en-US/auth.json +++ b/locales/en-US/auth.json @@ -94,9 +94,10 @@ "betterAuth.signin.orContinueWith": "OR", "betterAuth.signin.passwordPlaceholder": "Enter your password", "betterAuth.signin.passwordStep.subtitle": "Enter your password to continue", + "betterAuth.signin.setPassword": "set a password", "betterAuth.signin.signupLink": "Sign up now", "betterAuth.signin.socialError": "Social sign in failed, please try again", - "betterAuth.signin.socialOnlyHint": "This email was registered using a social account. Please sign in using the corresponding social provider.", + "betterAuth.signin.socialOnlyHint": "This email was registered via social login. Sign in with that provider, or", "betterAuth.signin.submit": "Sign In", "betterAuth.signup.confirmPasswordPlaceholder": "Confirm your password", "betterAuth.signup.emailPlaceholder": "Enter your email address", diff --git a/locales/zh-CN/auth.json b/locales/zh-CN/auth.json index 5f002448b0..d8ea112954 100644 --- a/locales/zh-CN/auth.json +++ b/locales/zh-CN/auth.json @@ -94,9 +94,10 @@ "betterAuth.signin.orContinueWith": "或继续使用", "betterAuth.signin.passwordPlaceholder": "请输入密码", "betterAuth.signin.passwordStep.subtitle": "请输入密码以继续", + "betterAuth.signin.setPassword": "设置密码", "betterAuth.signin.signupLink": "创建账号", "betterAuth.signin.socialError": "登录遇到了问题,请重试", - "betterAuth.signin.socialOnlyHint": "该邮箱使用第三方账号注册,请使用对应方式登录", + "betterAuth.signin.socialOnlyHint": "该邮箱使用第三方社交账号注册。请使用对应方式登录,或", "betterAuth.signin.submit": "登录", "betterAuth.signup.confirmPasswordPlaceholder": "请确认密码", "betterAuth.signup.emailPlaceholder": "请输入邮箱地址", diff --git a/scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts b/scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts new file mode 100644 index 0000000000..bed392d4f3 --- /dev/null +++ b/scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { parseCsvLine } from '../_internal/load-data-from-files'; + +describe('parseCsvLine', () => { + it('parses simple comma separated values', () => { + expect(parseCsvLine('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('handles quoted commas', () => { + expect(parseCsvLine('"a,b",c')).toEqual(['a,b', 'c']); + }); + + it('handles escaped quotes inside quoted field', () => { + expect(parseCsvLine('"a""b",c')).toEqual(['a"b', 'c']); + }); + + it('handles trailing empty fields', () => { + expect(parseCsvLine('a,b,')).toEqual(['a', 'b', '']); + }); +}); diff --git a/scripts/clerk-to-betterauth/_internal/config.ts b/scripts/clerk-to-betterauth/_internal/config.ts new file mode 100644 index 0000000000..0f90895129 --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/config.ts @@ -0,0 +1,55 @@ +import './env'; +import type { ClerkToBetterAuthMode, DatabaseDriver } from './types'; + +const DEFAULT_MODE: ClerkToBetterAuthMode = 'test'; +const DEFAULT_DATABASE_DRIVER: DatabaseDriver = 'neon'; + +export function getMigrationMode(): ClerkToBetterAuthMode { + const mode = process.env.CLERK_TO_BETTERAUTH_MODE; + if (mode === 'test' || mode === 'prod') return mode; + return DEFAULT_MODE; +} + +export function resolveDataPaths(mode = getMigrationMode()) { + const baseDir = `scripts/clerk-to-betterauth/${mode}`; + + return { + baseDir, + clerkCsvPath: `${baseDir}/clerk_exported_users.csv`, + clerkUsersPath: `${baseDir}/clerk_users.json`, + } as const; +} + +export function getDatabaseUrl(mode = getMigrationMode()): string { + const key = + mode === 'test' + ? 'TEST_CLERK_TO_BETTERAUTH_DATABASE_URL' + : 'PROD_CLERK_TO_BETTERAUTH_DATABASE_URL'; + const value = process.env[key]; + + if (!value) { + throw new Error(`${key} is not set`); + } + + return value; +} + +export function getClerkSecret(mode = getMigrationMode()): string { + const key = + mode === 'test' + ? 'TEST_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY' + : 'PROD_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY'; + const value = process.env[key]; + + if (!value) { + throw new Error(`${key} is required to export Clerk users`); + } + + return value; +} + +export function getDatabaseDriver(): DatabaseDriver { + const driver = process.env.CLERK_TO_BETTERAUTH_DATABASE_DRIVER; + if (driver === 'neon' || driver === 'node') return driver; + return DEFAULT_DATABASE_DRIVER; +} diff --git a/scripts/clerk-to-betterauth/_internal/db.ts b/scripts/clerk-to-betterauth/_internal/db.ts new file mode 100644 index 0000000000..e94469bc39 --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/db.ts @@ -0,0 +1,32 @@ +import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless'; +import { drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless'; +import { drizzle as nodeDrizzle } from 'drizzle-orm/node-postgres'; +import { Pool as NodePool } from 'pg'; +import ws from 'ws'; + +// schema is the only dependency on project code, required for type-safe migrations +import * as schemaModule from '../../../packages/database/src/schemas'; +import { getDatabaseDriver, getDatabaseUrl } from './config'; + +function createDatabase() { + const databaseUrl = getDatabaseUrl(); + const driver = getDatabaseDriver(); + + if (driver === 'node') { + const pool = new NodePool({ connectionString: databaseUrl }); + const db = nodeDrizzle(pool, { schema: schemaModule }); + return { db, pool }; + } + + // neon driver (default) + // https://github.com/neondatabase/serverless/blob/main/CONFIG.md#websocketconstructor-typeof-websocket--undefined + neonConfig.webSocketConstructor = ws; + const pool = new NeonPool({ connectionString: databaseUrl }); + const db = neonDrizzle(pool, { schema: schemaModule }); + return { db, pool }; +} + +const { db, pool } = createDatabase(); + +export { db, pool }; +export * as schema from '../../../packages/database/src/schemas'; diff --git a/scripts/clerk-to-betterauth/_internal/env.ts b/scripts/clerk-to-betterauth/_internal/env.ts new file mode 100644 index 0000000000..c9dd18097a --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/env.ts @@ -0,0 +1,6 @@ +import { existsSync } from 'node:fs'; +import { loadEnvFile } from 'node:process'; + +if (existsSync('.env')) { + loadEnvFile(); +} diff --git a/scripts/clerk-to-betterauth/_internal/load-data-from-files.ts b/scripts/clerk-to-betterauth/_internal/load-data-from-files.ts new file mode 100644 index 0000000000..109460c227 --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/load-data-from-files.ts @@ -0,0 +1,74 @@ +import { readFile } from 'node:fs/promises'; + +import { resolveDataPaths } from './config'; +import { CSVUserRow, ClerkUser } from './types'; + +export function parseCsvLine(line: string): string[] { + const cells: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + cells.push(current); + current = ''; + } else { + current += char; + } + } + + cells.push(current); + return cells; +} + +function parseCsv(text: string): Record[] { + const lines = text.split(/\r?\n/).filter((line) => line.length > 0); + if (lines.length === 0) return []; + + const headers = parseCsvLine(lines[0]).map((h) => h.trim()); + + return lines.slice(1).map((line) => { + const values = parseCsvLine(line); + const record: Record = {}; + headers.forEach((header, index) => { + record[header] = (values[index] ?? '').trim(); + }); + return record; + }); +} + +export async function loadCSVData(path = resolveDataPaths().clerkCsvPath): Promise { + const csv = await readFile(path, 'utf8'); + const jsonData = parseCsv(csv); + return jsonData as CSVUserRow[]; +} + +export async function loadClerkUsersFromFile( + path = resolveDataPaths().clerkUsersPath, +): Promise { + try { + const file = await readFile(path, 'utf8'); + const parsed = JSON.parse(file) as ClerkUser[]; + + if (!Array.isArray(parsed)) { + throw new Error('Parsed Clerk users is not an array'); + } + + return parsed; + } catch (error) { + const hint = ` +Failed to read Clerk users from ${path}. +请先运行: tsx scripts/clerk-to-betterauth/export-clerk-users-with-api.ts ${path} + `.trim(); + throw new Error(`${(error as Error).message}\n${hint}`); + } +} diff --git a/scripts/clerk-to-betterauth/_internal/types.ts b/scripts/clerk-to-betterauth/_internal/types.ts new file mode 100644 index 0000000000..69a28eba17 --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/types.ts @@ -0,0 +1,45 @@ +import type { ExternalAccountJSON, UserJSON } from '@clerk/backend'; + +export type ClerkToBetterAuthMode = 'test' | 'prod'; +export type DatabaseDriver = 'neon' | 'node'; + +export type CSVUserRow = { + first_name: string; + id: string; + last_name: string; + password_digest: string; + password_hasher: string; + primary_email_address: string; + primary_phone_number: string; + totp_secret: string; + unverified_email_addresses: string; + unverified_phone_numbers: string; + username: string; + verified_email_addresses: string; + verified_phone_numbers: string; +}; + +export type ClerkExternalAccount = Pick< + ExternalAccountJSON, + 'id' | 'provider' | 'provider_user_id' | 'approved_scopes' +> & { + created_at?: number; + updated_at?: number; + verificationStatus?: boolean; +}; + +export type ClerkUser = Pick< + UserJSON, + | 'id' + | 'image_url' + | 'created_at' + | 'updated_at' + | 'password_last_updated_at' + | 'password_enabled' + | 'banned' + | 'two_factor_enabled' + | 'lockout_expires_in_seconds' +> & { + external_accounts: ClerkExternalAccount[]; + primaryEmail?: string; +}; diff --git a/scripts/clerk-to-betterauth/_internal/utils.ts b/scripts/clerk-to-betterauth/_internal/utils.ts new file mode 100644 index 0000000000..c4d396a118 --- /dev/null +++ b/scripts/clerk-to-betterauth/_internal/utils.ts @@ -0,0 +1,36 @@ +import { generateRandomString, symmetricEncrypt } from 'better-auth/crypto'; + +export async function generateBackupCodes(secret: string) { + const key = secret; + const backupCodes = Array.from({ length: 10 }) + .fill(null) + .map(() => generateRandomString(10, 'a-z', '0-9', 'A-Z')) + .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`); + const encCodes = await symmetricEncrypt({ + data: JSON.stringify(backupCodes), + key: key, + }); + return encCodes; +} + +// Helper function to safely convert timestamp to Date +export function safeDateConversion(timestamp?: number): Date { + if (!timestamp) return new Date(); + + const date = new Date(timestamp); + + // Check if the date is valid + if (isNaN(date.getTime())) { + console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`); + return new Date(); + } + + // Check for unreasonable dates (before 2000 or after 2100) + const year = date.getFullYear(); + if (year < 2000 || year > 2100) { + console.warn(`Suspicious date year: ${year}, falling back to current date`); + return new Date(); + } + + return date; +} diff --git a/scripts/clerk-to-betterauth/export-clerk-users-with-api.ts b/scripts/clerk-to-betterauth/export-clerk-users-with-api.ts new file mode 100644 index 0000000000..d7223e225e --- /dev/null +++ b/scripts/clerk-to-betterauth/export-clerk-users-with-api.ts @@ -0,0 +1,211 @@ +/* eslint-disable unicorn/prefer-top-level-await, unicorn/no-process-exit */ +import { type User, createClerkClient } from '@clerk/backend'; +import { writeFile } from 'node:fs/promises'; + +import { getClerkSecret, getMigrationMode, resolveDataPaths } from './_internal/config'; +import './_internal/env'; +import { ClerkUser } from './_internal/types'; + +/** + * Fetch all Clerk users via REST API and persist them into a local JSON file. + * + * Usage: + * tsx scripts/clerk-to-betterauth/export-clerk-users.ts [outputPath] + * + * Env vars required (set by CLERK_TO_BETTERAUTH_MODE=test|prod): + * - TEST_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY (test) + * - PROD_CLERK_TO_BETTERAUTH_CLERK_SECRET_KEY (prod) + */ +const PAGE_SIZE = 500; +const CONCURRENCY = Number(process.env.CLERK_EXPORT_CONCURRENCY ?? 10); +const MAX_RETRIES = Number(process.env.CLERK_EXPORT_RETRIES ?? 10); +const RETRY_DELAY_MS = 1000; +const ORDER_BY = '+created_at'; +const DEFAULT_OUTPUT_PATH = resolveDataPaths().clerkUsersPath; +const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +function getClerkClient(secretKey: string) { + return createClerkClient({ + secretKey, + }); +} + +function mapClerkUser(user: User): ClerkUser { + const raw = user.raw!; + + const primaryEmail = raw.email_addresses?.find( + (email) => email.id === raw.primary_email_address_id, + )?.email_address; + + return { + banned: raw.banned, + created_at: raw.created_at, + external_accounts: (raw.external_accounts ?? []).map((acc) => ({ + approved_scopes: acc.approved_scopes, + created_at: (acc as any).created_at, + id: acc.id, + provider: acc.provider, + provider_user_id: acc.provider_user_id, + updated_at: (acc as any).updated_at, + verificationStatus: acc.verification?.status === 'verified', + })), + id: raw.id, + image_url: raw.image_url, + lockout_expires_in_seconds: raw.lockout_expires_in_seconds, + password_enabled: raw.password_enabled, + password_last_updated_at: raw.password_last_updated_at, + primaryEmail, + two_factor_enabled: raw.two_factor_enabled, + updated_at: raw.updated_at, + } satisfies ClerkUser; +} + +async function fetchClerkUserPage( + offset: number, + clerkClient: ReturnType, + pageIndex: number, +): Promise { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) { + try { + console.log( + `🚚 [clerk-export] Fetching page #${pageIndex + 1} offset=${offset} limit=${PAGE_SIZE} (attempt ${attempt}/${MAX_RETRIES})`, + ); + + const { data } = await clerkClient.users.getUserList({ + limit: PAGE_SIZE, + offset, + orderBy: ORDER_BY, + }); + + console.log( + `📥 [clerk-export] Received page #${pageIndex + 1} (${data.length} users) offset=${offset}`, + ); + + return data.map(mapClerkUser); + } catch (error) { + const isLastAttempt = attempt === MAX_RETRIES; + const message = error instanceof Error ? error.message : String(error); + console.warn( + `⚠️ [clerk-export] Page #${pageIndex + 1} offset=${offset} failed (attempt ${attempt}/${MAX_RETRIES}): ${message}`, + ); + + if (isLastAttempt) { + throw error; + } + + await sleep(RETRY_DELAY_MS); + } + } + + // Unreachable, but satisfies TypeScript return. + return []; +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise, +): Promise { + const queue = [...items]; + const inFlight: Promise[] = []; + let index = 0; + + const launchNext = () => { + if (!queue.length) return; + const currentItem = queue.shift() as T; + const currentIndex = index; + index += 1; + const task = worker(currentItem, currentIndex).finally(() => { + const pos = inFlight.indexOf(task); + if (pos !== -1) inFlight.splice(pos, 1); + }); + inFlight.push(task); + }; + + for (let i = 0; i < concurrency && queue.length; i += 1) { + launchNext(); + } + + while (inFlight.length) { + await Promise.race(inFlight); + launchNext(); + } +} + +async function fetchAllClerkUsers(secretKey: string): Promise { + const clerkClient = getClerkClient(secretKey); + const userMap = new Map(); + + const firstPageResponse = await clerkClient.users.getUserList({ + limit: PAGE_SIZE, + offset: 0, + orderBy: ORDER_BY, + }); + + const totalCount = firstPageResponse.totalCount ?? firstPageResponse.data.length; + const totalPages = Math.ceil(totalCount / PAGE_SIZE); + const offsets = Array.from({ length: totalPages }, (_, pageIndex) => pageIndex * PAGE_SIZE); + + console.log( + `📊 [clerk-export] Total users: ${totalCount}. Pages: ${totalPages}. Concurrency: ${CONCURRENCY}.`, + ); + + await runWithConcurrency(offsets, CONCURRENCY, async (offset, index) => { + const page = await fetchClerkUserPage(offset, clerkClient, index); + + for (const user of page) { + userMap.set(user.id, user); + } + + if ((index + 1) % CONCURRENCY === 0 || index === offsets.length - 1) { + console.log( + `⏳ [clerk-export] Progress: ${userMap.size}/${totalCount} unique users collected.`, + ); + } + }); + + const uniqueCount = userMap.size; + const extraUsers = Math.max(0, uniqueCount - totalCount); + + console.log( + `🆕 [clerk-export] Snapshot total=${totalCount}, final unique=${uniqueCount}, new during export=${extraUsers}`, + ); + + return Array.from(userMap.values()); +} + +async function main() { + const startedAt = Date.now(); + const mode = getMigrationMode(); + const secretKey = getClerkSecret(); + const outputPath = process.argv[2] ?? DEFAULT_OUTPUT_PATH; + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Clerk Users Export Script (via API) ║'); + console.log('╠════════════════════════════════════════════════════════════╣'); + console.log(`║ Mode: ${mode.padEnd(48)}║`); + console.log(`║ Output: ${outputPath.padEnd(48)}║`); + console.log('╚════════════════════════════════════════════════════════════╝'); + console.log(''); + + const clerkUsers = await fetchAllClerkUsers(secretKey); + + await writeFile(outputPath, JSON.stringify(clerkUsers, null, 2), 'utf8'); + + console.log(''); + console.log( + `✅ Export success! Saved ${clerkUsers.length} users to ${outputPath} (${formatDuration(Date.now() - startedAt)})`, + ); +} + +void main().catch((error) => { + console.log(''); + console.error('❌ Export failed:', error); + process.exit(1); +}); diff --git a/scripts/clerk-to-betterauth/index.ts b/scripts/clerk-to-betterauth/index.ts new file mode 100644 index 0000000000..9722f68e02 --- /dev/null +++ b/scripts/clerk-to-betterauth/index.ts @@ -0,0 +1,314 @@ +/* eslint-disable unicorn/prefer-top-level-await */ +import { sql } from 'drizzle-orm'; + +import { getMigrationMode } from './_internal/config'; +import { db, pool, schema } from './_internal/db'; +import { loadCSVData, loadClerkUsersFromFile } from './_internal/load-data-from-files'; +import { ClerkExternalAccount } from './_internal/types'; +import { generateBackupCodes, safeDateConversion } from './_internal/utils'; + +const BATCH_SIZE = Number(process.env.CLERK_TO_BETTERAUTH_BATCH_SIZE) || 300; +const PROGRESS_TABLE = sql.identifier('clerk_migration_progress'); +const IS_DRY_RUN = + process.argv.includes('--dry-run') || process.env.CLERK_TO_BETTERAUTH_DRY_RUN === '1'; +const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`; + +function chunk(items: T[], size: number): T[][] { + if (!Number.isFinite(size) || size <= 0) return [items]; + const result: T[][] = []; + for (let i = 0; i < items.length; i += size) { + result.push(items.slice(i, i + size)); + } + return result; +} + +function computeBanExpires(lockoutSeconds?: number | null): Date | undefined { + if (typeof lockoutSeconds !== 'number') return undefined; + return new Date(Date.now() + lockoutSeconds * 1000); +} + +async function migrateFromClerk() { + const mode = getMigrationMode(); + const csvUsers = await loadCSVData(); + const clerkUsers = await loadClerkUsersFromFile(); + const clerkUserMap = new Map(clerkUsers.map((u) => [u.id, u])); + + if (!IS_DRY_RUN) { + await db.execute(sql` + CREATE TABLE IF NOT EXISTS ${PROGRESS_TABLE} ( + user_id TEXT PRIMARY KEY, + processed_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + } + + const processedUsers = new Set(); + + if (!IS_DRY_RUN) { + try { + const processedResult = await db.execute<{ user_id: string }>( + sql`SELECT user_id FROM ${PROGRESS_TABLE};`, + ); + const rows = (processedResult as { rows?: { user_id: string }[] }).rows ?? []; + + for (const row of rows) { + const userId = row?.user_id; + if (typeof userId === 'string') { + processedUsers.add(userId); + } + } + } catch (error) { + console.warn('[clerk-to-betterauth] failed to read progress table, treating as empty', error); + } + } + + console.log(`[clerk-to-betterauth] mode: ${mode} (dryRun=${IS_DRY_RUN})`); + console.log(`[clerk-to-betterauth] csv users: ${csvUsers.length}`); + console.log(`[clerk-to-betterauth] clerk api users: ${clerkUsers.length}`); + console.log(`[clerk-to-betterauth] already processed: ${processedUsers.size}`); + + const unprocessedUsers = csvUsers.filter((user) => !processedUsers.has(user.id)); + const batches = chunk(unprocessedUsers, BATCH_SIZE); + console.log( + `[clerk-to-betterauth] batches: ${batches.length} (batchSize=${BATCH_SIZE}, toProcess=${unprocessedUsers.length})`, + ); + + let processed = 0; + let accountAttempts = 0; + let twoFactorAttempts = 0; + let skipped = csvUsers.length - unprocessedUsers.length; + const startedAt = Date.now(); + const accountCounts: Record = {}; + let missingScopeNonCredential = 0; + let passwordEnabledButNoDigest = 0; + const sampleMissingScope: string[] = []; + const sampleMissingDigest: string[] = []; + + const bumpAccountCount = (providerId: string) => { + accountCounts[providerId] = (accountCounts[providerId] ?? 0) + 1; + }; + + for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { + const batch = batches[batchIndex]; + const userRows: (typeof schema.users.$inferInsert)[] = []; + const accountRows: (typeof schema.account.$inferInsert)[] = []; + const twoFactorRows: (typeof schema.twoFactor.$inferInsert)[] = []; + + for (const user of batch) { + const clerkUser = clerkUserMap.get(user.id); + const lockoutSeconds = clerkUser?.lockout_expires_in_seconds; + const externalAccounts = clerkUser?.external_accounts as ClerkExternalAccount[] | undefined; + + const userRow: typeof schema.users.$inferInsert = { + avatar: clerkUser?.image_url, + banExpires: computeBanExpires(lockoutSeconds) ?? undefined, + banned: Boolean(clerkUser?.banned), + clerkCreatedAt: safeDateConversion(clerkUser?.created_at), + email: user.primary_email_address, + emailVerified: Boolean(user.verified_email_addresses?.length), + firstName: user.first_name || undefined, + id: user.id, + lastName: user.last_name || undefined, + phone: user.primary_phone_number || undefined, + phoneNumberVerified: Boolean(user.verified_phone_numbers?.length), + role: 'user', + twoFactorEnabled: Boolean(clerkUser?.two_factor_enabled), + username: user.username || undefined, + }; + userRows.push(userRow); + + if (externalAccounts) { + for (const externalAccount of externalAccounts) { + const provider = externalAccount.provider; + const providerUserId = externalAccount.provider_user_id; + + /** + * Clerk external accounts never contain credential providers and always include provider_user_id. + * Enforce this assumption to avoid inserting invalid account rows. + */ + if (provider === 'credential') { + throw new Error( + `[clerk-to-betterauth] unexpected credential external account: userId=${user.id}, externalAccountId=${externalAccount.id}`, + ); + } + if (!providerUserId) { + throw new Error( + `[clerk-to-betterauth] missing provider_user_id: userId=${user.id}, externalAccountId=${externalAccount.id}, provider=${provider}`, + ); + } + + const providerId = provider.replace('oauth_', ''); + + if (!externalAccount.approved_scopes) { + missingScopeNonCredential += 1; + if (sampleMissingScope.length < 5) sampleMissingScope.push(user.id); + } + + accountRows.push({ + accountId: providerUserId, + createdAt: safeDateConversion(externalAccount.created_at), + id: externalAccount.id, + providerId, + scope: externalAccount.approved_scopes?.replace(/\s+/g, ',') || undefined, + updatedAt: safeDateConversion(externalAccount.updated_at), + userId: user.id, + }); + accountAttempts += 1; + bumpAccountCount(providerId); + } + } + + // Clerk API 不返回 credential external_account;若用户开启密码并且 CSV 提供散列,则补齐本地密码账号 + const passwordEnabled = Boolean(clerkUser?.password_enabled); + if (passwordEnabled && user.password_digest) { + const passwordUpdatedAt = clerkUser?.password_last_updated_at; + + accountRows.push({ + accountId: user.id, + createdAt: safeDateConversion(clerkUser?.created_at), + id: 'cred_' + user.id, + password: user.password_digest, + providerId: 'credential', + updatedAt: safeDateConversion( + passwordUpdatedAt ?? clerkUser?.updated_at ?? clerkUser?.created_at, + ), + userId: user.id, + }); + accountAttempts += 1; + bumpAccountCount('credential'); + } else if (passwordEnabled && !user.password_digest) { + passwordEnabledButNoDigest += 1; + if (sampleMissingDigest.length < 5) sampleMissingDigest.push(user.id); + } + + if (user.totp_secret) { + twoFactorRows.push({ + backupCodes: await generateBackupCodes(user.totp_secret), + id: `tf_${user.id}`, + secret: user.totp_secret, + userId: user.id, + }); + twoFactorAttempts += 1; + } + } + + if (!IS_DRY_RUN) { + await db.transaction(async (tx) => { + await tx + .insert(schema.users) + .values(userRows) + .onConflictDoUpdate({ + set: { + avatar: sql`excluded.avatar`, + banExpires: sql`excluded.ban_expires`, + banned: sql`excluded.banned`, + clerkCreatedAt: sql`excluded.clerk_created_at`, + email: sql`excluded.email`, + emailVerified: sql`excluded.email_verified`, + firstName: sql`excluded.first_name`, + lastName: sql`excluded.last_name`, + phone: sql`excluded.phone`, + phoneNumberVerified: sql`excluded.phone_number_verified`, + role: sql`excluded.role`, + twoFactorEnabled: sql`excluded.two_factor_enabled`, + username: sql`excluded.username`, + }, + target: schema.users.id, + }); + + if (accountRows.length > 0) { + await tx.insert(schema.account).values(accountRows).onConflictDoNothing(); + } + + if (twoFactorRows.length > 0) { + await tx.insert(schema.twoFactor).values(twoFactorRows).onConflictDoNothing(); + } + + const userIdValues = userRows.map((row) => sql`(${row.id})`); + if (userIdValues.length > 0) { + await tx.execute(sql` + INSERT INTO ${PROGRESS_TABLE} (user_id) + VALUES ${sql.join(userIdValues, sql`, `)} + ON CONFLICT (user_id) DO NOTHING; + `); + } + }); + } + processed += batch.length; + console.log( + `[clerk-to-betterauth] batch ${batchIndex + 1}/${batches.length} done, users ${processed}/${unprocessedUsers.length}, accounts+=${accountRows.length}, 2fa+=${twoFactorRows.length}, dryRun=${IS_DRY_RUN}`, + ); + } + + console.log( + `[clerk-to-betterauth] completed users=${processed}, skipped=${skipped}, accounts attempted=${accountAttempts}, 2fa attempted=${twoFactorAttempts}, dryRun=${IS_DRY_RUN}, elapsed=${formatDuration(Date.now() - startedAt)}`, + ); + + const accountCountsText = Object.entries(accountCounts) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([providerId, count]) => `${providerId}=${count}`) + .join(', '); + + console.log( + `[clerk-to-betterauth] account provider counts: ${accountCountsText || 'none recorded'}`, + ); + + console.log( + [ + '[clerk-to-betterauth] anomalies:', + ` - missing scope (non-credential): ${missingScopeNonCredential} sample=${sampleMissingScope.join(';') || 'n/a'}`, + ` - passwordEnabled without digest: ${passwordEnabledButNoDigest} sample=${sampleMissingDigest.join(';') || 'n/a'}`, + ].join('\n'), + ); +} +async function main() { + const startedAt = Date.now(); + const mode = getMigrationMode(); + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Clerk to Better Auth Migration Script ║'); + console.log('╠════════════════════════════════════════════════════════════╣'); + console.log(`║ Mode: ${mode.padEnd(48)}║`); + console.log(`║ Dry Run: ${(IS_DRY_RUN ? 'YES (no changes will be made)' : 'NO').padEnd(48)}║`); + console.log(`║ Batch: ${String(BATCH_SIZE).padEnd(48)}║`); + console.log('╚════════════════════════════════════════════════════════════╝'); + console.log(''); + + if (mode === 'prod' && !IS_DRY_RUN) { + console.log('⚠️ WARNING: Running in PRODUCTION mode. Data will be modified!'); + console.log(' Type "yes" to continue or press Ctrl+C to abort.'); + console.log(''); + + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(' Confirm (yes/no): ', (ans) => { + resolve(ans); + }); + }); + rl.close(); + + if (answer.toLowerCase() !== 'yes') { + console.log('❌ Aborted by user.'); + process.exitCode = 0; + await pool.end(); + return; + } + console.log(''); + } + + try { + await migrateFromClerk(); + console.log(''); + console.log(`✅ Migration success! (${formatDuration(Date.now() - startedAt)})`); + } catch (error) { + console.log(''); + console.error(`❌ Migration failed (${formatDuration(Date.now() - startedAt)}):`, error); + process.exitCode = 1; + } finally { + await pool.end(); + } +} + +void main(); diff --git a/scripts/clerk-to-betterauth/prod/put_clerk_exported_users_csv_here.txt b/scripts/clerk-to-betterauth/prod/put_clerk_exported_users_csv_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/clerk-to-betterauth/test/put_clerk_exported_users_csv_here.txt b/scripts/clerk-to-betterauth/test/put_clerk_exported_users_csv_here.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/clerk-to-betterauth/verify.ts b/scripts/clerk-to-betterauth/verify.ts new file mode 100644 index 0000000000..a9cc56de18 --- /dev/null +++ b/scripts/clerk-to-betterauth/verify.ts @@ -0,0 +1,275 @@ +/* eslint-disable unicorn/prefer-top-level-await */ +import { getMigrationMode, resolveDataPaths } from './_internal/config'; +import { db, pool, schema } from './_internal/db'; +import { loadCSVData, loadClerkUsersFromFile } from './_internal/load-data-from-files'; +import { ClerkExternalAccount, ClerkUser } from './_internal/types'; + +type ExpectedAccount = { + accountId?: string; + providerId: string; + scope?: string; + userId: string; +}; + +type ActualAccount = { + accountId: string | null; + providerId: string; + scope: string | null; + userId: string; +}; + +const MAX_SAMPLES = 5; + +const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`; + +function providerIdFromExternal(external: ClerkExternalAccount): string { + return external.provider === 'credential' + ? 'credential' + : external.provider.replace('oauth_', ''); +} + +function buildExpectedAccounts( + csvUsers: Awaited>, + clerkUsers: ClerkUser[], +) { + const clerkMap = new Map(clerkUsers.map((u) => [u.id, u])); + + const expectedAccounts: ExpectedAccount[] = []; + const expectedTwoFactorUsers = new Set(); + let passwordEnabledWithoutDigest = 0; + const passwordEnabledWithoutDigestSamples: string[] = []; + + for (const user of csvUsers) { + const clerkUser = clerkMap.get(user.id); + const externalAccounts = clerkUser?.external_accounts as ClerkExternalAccount[] | undefined; + + if (externalAccounts) { + for (const external of externalAccounts) { + expectedAccounts.push({ + accountId: external.provider_user_id, + providerId: providerIdFromExternal(external), + scope: external.approved_scopes?.replace(/\s+/g, ','), + userId: user.id, + }); + } + } + + const passwordEnabled = Boolean(clerkUser?.password_enabled); + if (passwordEnabled && user.password_digest) { + expectedAccounts.push({ + accountId: user.id, + providerId: 'credential', + scope: undefined, + userId: user.id, + }); + } else if (passwordEnabled && !user.password_digest) { + passwordEnabledWithoutDigest += 1; + if (passwordEnabledWithoutDigestSamples.length < MAX_SAMPLES) { + passwordEnabledWithoutDigestSamples.push(user.id); + } + } + + if (user.totp_secret) { + expectedTwoFactorUsers.add(user.id); + } + } + + return { + expectedAccounts, + expectedTwoFactorUsers, + passwordEnabledWithoutDigest, + passwordEnabledWithoutDigestSamples, + }; +} + +function buildAccountKey(account: { + accountId?: string | null; + providerId: string; + userId: string; +}) { + return `${account.userId}__${account.providerId}__${account.accountId ?? ''}`; +} + +async function loadActualAccounts() { + const rows = await db + .select({ + accountId: schema.account.accountId, + providerId: schema.account.providerId, + scope: schema.account.scope, + userId: schema.account.userId, + }) + .from(schema.account); + + return rows as ActualAccount[]; +} + +async function loadActualTwoFactorUserIds() { + const rows = await db.select({ userId: schema.twoFactor.userId }).from(schema.twoFactor); + return new Set(rows.map((row) => row.userId)); +} + +async function loadActualUserIds() { + const rows = await db.select({ id: schema.users.id }).from(schema.users); + return new Set(rows.map((row) => row.id)); +} + +async function main() { + const startedAt = Date.now(); + const mode = getMigrationMode(); + const { clerkCsvPath, clerkUsersPath } = resolveDataPaths(mode); + + console.log(''); + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Migration Verification Script ║'); + console.log('╠════════════════════════════════════════════════════════════╣'); + console.log(`║ Mode: ${mode.padEnd(48)}║`); + console.log(`║ CSV: ${clerkCsvPath.padEnd(48)}║`); + console.log(`║ JSON: ${clerkUsersPath.padEnd(48)}║`); + console.log('╚════════════════════════════════════════════════════════════╝'); + console.log(''); + + const [csvUsers, clerkUsers] = await Promise.all([ + loadCSVData(clerkCsvPath), + loadClerkUsersFromFile(clerkUsersPath), + ]); + + console.log( + `📦 [verify] Loaded csvUsers=${csvUsers.length}, clerkUsers=${clerkUsers.length} (unique ids=${ + new Set(clerkUsers.map((u) => u.id)).size + })`, + ); + + const { + expectedAccounts, + expectedTwoFactorUsers, + passwordEnabledWithoutDigest, + passwordEnabledWithoutDigestSamples, + } = buildExpectedAccounts(csvUsers, clerkUsers); + + console.log( + `🧮 [verify] Expected accounts=${expectedAccounts.length}, expected 2FA users=${expectedTwoFactorUsers.size}, passwordEnabledWithoutDigest=${passwordEnabledWithoutDigest} sample=${ + passwordEnabledWithoutDigestSamples.join(', ') || 'n/a' + }`, + ); + + const [actualAccounts, actualTwoFactorUserIds, actualUserIds] = await Promise.all([ + loadActualAccounts(), + loadActualTwoFactorUserIds(), + loadActualUserIds(), + ]); + + console.log( + `🗄️ [verify] DB snapshot: users=${actualUserIds.size}, accounts=${actualAccounts.length}, twoFactor=${actualTwoFactorUserIds.size}`, + ); + + let missingUsers = 0; + const missingUserSamples: string[] = []; + for (const user of csvUsers) { + if (!actualUserIds.has(user.id)) { + missingUsers += 1; + if (missingUserSamples.length < MAX_SAMPLES) missingUserSamples.push(user.id); + } + } + + const expectedAccountSet = new Set(expectedAccounts.map(buildAccountKey)); + const actualAccountSet = new Set(actualAccounts.map(buildAccountKey)); + + let missingAccounts = 0; + const missingAccountSamples: string[] = []; + for (const account of expectedAccounts) { + const key = buildAccountKey(account); + if (!actualAccountSet.has(key)) { + missingAccounts += 1; + if (missingAccountSamples.length < MAX_SAMPLES) missingAccountSamples.push(account.userId); + } + } + + let unexpectedAccounts = 0; + const unexpectedAccountSamples: string[] = []; + for (const account of actualAccounts) { + const key = buildAccountKey(account); + if (!expectedAccountSet.has(key)) { + unexpectedAccounts += 1; + if (unexpectedAccountSamples.length < MAX_SAMPLES) + unexpectedAccountSamples.push(account.userId); + } + } + + let missingTwoFactor = 0; + const missingTwoFactorSamples: string[] = []; + for (const userId of expectedTwoFactorUsers) { + if (!actualTwoFactorUserIds.has(userId)) { + missingTwoFactor += 1; + if (missingTwoFactorSamples.length < MAX_SAMPLES) missingTwoFactorSamples.push(userId); + } + } + + let missingScopeNonCredential = 0; + let missingAccountIdNonCredential = 0; + const sampleMissingScope: string[] = []; + const sampleMissingAccountId: string[] = []; + + for (const account of actualAccounts) { + if (account.providerId !== 'credential') { + if (!account.scope) { + missingScopeNonCredential += 1; + if (sampleMissingScope.length < MAX_SAMPLES) sampleMissingScope.push(account.userId); + } + if (!account.accountId) { + missingAccountIdNonCredential += 1; + if (sampleMissingAccountId.length < MAX_SAMPLES) + sampleMissingAccountId.push(account.userId); + } + } + } + + const expectedProviderCounts: Record = {}; + const actualProviderCounts: Record = {}; + + for (const account of expectedAccounts) { + expectedProviderCounts[account.providerId] = + (expectedProviderCounts[account.providerId] ?? 0) + 1; + } + + for (const account of actualAccounts) { + actualProviderCounts[account.providerId] = (actualProviderCounts[account.providerId] ?? 0) + 1; + } + + const formatCounts = (counts: Record) => + Object.entries(counts) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([providerId, count]) => `${providerId}=${count}`) + .join(', '); + + console.log( + `📊 [verify] Expected provider counts: ${formatCounts(expectedProviderCounts) || 'n/a'}`, + ); + console.log( + `📊 [verify] Actual provider counts: ${formatCounts(actualProviderCounts) || 'n/a'}`, + ); + + console.log( + `✅ [verify] Missing users=${missingUsers} sample=${missingUserSamples.join(', ') || 'n/a'}, missing accounts=${missingAccounts} sample=${missingAccountSamples.join(', ') || 'n/a'}, unexpected accounts=${unexpectedAccounts} sample=${unexpectedAccountSamples.join(', ') || 'n/a'}`, + ); + + console.log( + `🔐 [verify] Two-factor missing=${missingTwoFactor} sample=${missingTwoFactorSamples.join(', ') || 'n/a'}`, + ); + + console.log( + `⚠️ [verify] Non-credential missing scope=${missingScopeNonCredential} sample=${sampleMissingScope.join(', ') || 'n/a'}, missing account_id=${missingAccountIdNonCredential} sample=${sampleMissingAccountId.join(', ') || 'n/a'}`, + ); + + console.log(''); + console.log(`✅ Verification success! (${formatDuration(Date.now() - startedAt)})`); +} + +void main() + .catch((error) => { + console.log(''); + console.error('❌ Verification failed:', error); + process.exitCode = 1; + }) + .finally(async () => { + await pool.end(); + }); diff --git a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx b/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx index 3fea177c26..da7387e80c 100644 --- a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx +++ b/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx @@ -1,7 +1,8 @@ import { BRANDING_NAME } from '@lobechat/business-const'; -import { Button, Flexbox, Icon, Input, Skeleton, Text } from '@lobehub/ui'; +import { Alert, Button, Flexbox, Icon, Input, Skeleton, Text } from '@lobehub/ui'; import { Divider, Form } from 'antd'; import type { FormInstance, InputRef } from 'antd'; +import { createStaticStyles } from 'antd-style'; import { ChevronRight, Mail } from 'lucide-react'; import { useEffect, useRef } from 'react'; import { Trans, useTranslation } from 'react-i18next'; @@ -11,14 +12,24 @@ import { PRIVACY_URL, TERMS_URL } from '@/const/url'; import AuthCard from '../../../../features/AuthCard'; +const styles = createStaticStyles(({ css, cssVar }) => ({ + setPasswordLink: css` + cursor: pointer; + color: ${cssVar.colorPrimary}; + text-decoration: underline; + `, +})); + export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const USERNAME_REGEX = /^\w+$/; export interface SignInEmailStepProps { form: FormInstance<{ email: string }>; + isSocialOnly: boolean; loading: boolean; oAuthSSOProviders: string[]; onCheckUser: (values: { email: string }) => Promise; + onSetPassword: () => void; onSocialSignIn: (provider: string) => void; serverConfigInit: boolean; socialLoading: string | null; @@ -26,11 +37,13 @@ export interface SignInEmailStepProps { export const SignInEmailStep = ({ form, + isSocialOnly, loading, oAuthSSOProviders, serverConfigInit, socialLoading, onCheckUser, + onSetPassword, onSocialSignIn, }: SignInEmailStepProps) => { const { t } = useTranslation('auth'); @@ -172,6 +185,21 @@ export const SignInEmailStep = ({ /> + {isSocialOnly && ( + + {t('betterAuth.signin.socialOnlyHint')}{' '} + + {t('betterAuth.signin.setPassword')} + + + } + showIcon + style={{ marginTop: 12 }} + type="info" + /> + )} ); }; diff --git a/src/app/[variants]/(auth)/signin/page.tsx b/src/app/[variants]/(auth)/signin/page.tsx index 9c4f5fcb07..5a0a323c8a 100644 --- a/src/app/[variants]/(auth)/signin/page.tsx +++ b/src/app/[variants]/(auth)/signin/page.tsx @@ -17,6 +17,7 @@ const SignInPage = () => { handleForgotPassword, handleSignIn, handleSocialSignIn, + isSocialOnly, loading, oAuthSSOProviders, serverConfigInit, @@ -29,9 +30,11 @@ const SignInPage = () => { {step === 'email' ? ( { const [socialLoading, setSocialLoading] = useState(null); const [step, setStep] = useState('email'); const [email, setEmail] = useState(''); + const [isSocialOnly, setIsSocialOnly] = useState(false); const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit); const oAuthSSOProviders = useServerConfigStore((s) => s.serverConfig.oAuthSSOProviders) || []; const { ssoProviders, preSocialSigninCheck, getAdditionalData } = useBusinessSignin(); @@ -142,7 +143,8 @@ export const useSignIn = () => { return; } - message.info(t('betterAuth.signin.socialOnlyHint')); + // User has no password and magic link is disabled, they can only sign in via social + setIsSocialOnly(true); } catch (error) { console.error('Error checking user:', error); message.error(t('betterAuth.signin.error')); @@ -215,6 +217,7 @@ export const useSignIn = () => { const handleBackToEmail = () => { setStep('email'); setEmail(''); + setIsSocialOnly(false); }; const handleGoToSignup = () => { @@ -247,6 +250,7 @@ export const useSignIn = () => { handleGoToSignup, handleSignIn, handleSocialSignIn, + isSocialOnly, loading, oAuthSSOProviders: ENABLE_BUSINESS_FEATURES ? ssoProviders : oAuthSSOProviders, serverConfigInit: ENABLE_BUSINESS_FEATURES ? true : serverConfigInit, diff --git a/src/locales/default/auth.ts b/src/locales/default/auth.ts index 8316430249..5823f5ee7d 100644 --- a/src/locales/default/auth.ts +++ b/src/locales/default/auth.ts @@ -97,10 +97,11 @@ export default { 'betterAuth.signin.orContinueWith': 'OR', 'betterAuth.signin.passwordPlaceholder': 'Enter your password', 'betterAuth.signin.passwordStep.subtitle': 'Enter your password to continue', + 'betterAuth.signin.setPassword': 'set a password', 'betterAuth.signin.signupLink': 'Sign up now', 'betterAuth.signin.socialError': 'Social sign in failed, please try again', 'betterAuth.signin.socialOnlyHint': - 'This email was registered using a social account. Please sign in using the corresponding social provider.', + 'This email was registered via a third-party social account. Sign in with that provider, or', 'betterAuth.signin.submit': 'Sign In', 'betterAuth.signup.confirmPasswordPlaceholder': 'Confirm your password', 'betterAuth.signup.emailPlaceholder': 'Enter your email address',