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.
+
+
+
+
+### 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
+
+ 
+
+ 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.
+
+
+
+#### 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` 登录将会**创建新用户**,而非关联到原有账户。
+
+
+
+
+### 步骤
+
+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)登录
+ - 点击「设置密码」链接,通过邮件设置新密码后登录
+
+ 
+
+ 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)。
+
+
+
+#### 通过 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',