📝 docs: add clerk to betterauth migration scripts and enhance auth docs (#11701)

* 🔧 chore: add clerk to betterauth migration scripts

* 🔧 chore: support node-postgres driver for migration scripts

* 🔥 chore: remove unnecessary chore scripts

* ♻️ refactor: reorganize migration scripts directory structure

* 📝 docs: add example column to email service configuration table

* 📝 docs: rename auth/better-auth to auth/providers

* 📝 docs: enhance email service configuration with detailed guides

* 📝 docs: add Clerk to Better Auth migration guide

- Add migration documentation (EN & CN) with step-by-step instructions
- Add dry-run environment variable for safe testing
- Enhance script output with success/failure emojis
- Add placeholder files for migration data directories
- Update .gitignore to exclude migration data files

*  feat(auth): add set password option for social-only users

- Add isSocialOnly state to detect users without password
- Show Alert with "set password" link when magic link is disabled
- Update migration docs to clarify Magic Link vs non-Magic Link scenarios
- Add profile page password management info to docs

* ♻️ refactor: improve migration safety and sign-in link styling

- Add production mode confirmation prompt requiring "yes" input
- Use createStaticStyles for setPassword link styling with theme token

* 📝 docs: clarify migration script requirements and remove invalid links

* 📝 docs: add clerk migration guide link to legacy auth docs

* ♻️ refactor: enforce strict validation for clerk external accounts

* 📝 docs: add step to disable new user registration before migration

* 🐛 fix: handle missing .env file in migration scripts
This commit is contained in:
YuTengjing
2026-01-22 19:54:08 +08:00
committed by GitHub
parent 6034e5fe85
commit bfe387c027
59 changed files with 1961 additions and 64 deletions

6
.gitignore vendored
View File

@@ -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*

View File

@@ -60,39 +60,39 @@ To enable Better Auth in LobeChat, set the following environment variables:
Click on a provider below for detailed configuration guides:
<Cards>
<Card href={'/docs/self-hosting/advanced/auth/better-auth/github'} title={'GitHub'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/github'} title={'GitHub'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/google'} title={'Google'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/google'} title={'Google'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/microsoft'} title={'Microsoft'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/apple'} title={'Apple'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/apple'} title={'Apple'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/cognito'} title={'AWS Cognito'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/casdoor'} title={'Casdoor'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/logto'} title={'Logto'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/okta'} title={'Okta'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/okta'} title={'Okta'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/feishu'} title={'Feishu'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/feishu'} title={'Feishu'} />
<Card href={'/docs/self-hosting/advanced/auth/better-auth/wechat'} title={'WeChat'} />
<Card href={'/docs/self-hosting/advanced/auth/providers/wechat'} title={'WeChat'} />
</Cards>
## 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` |
<Callout type={'warning'}>
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).
</Callout>
### 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` |
<Callout type={'info'}>
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.
</Callout>
### 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

View File

@@ -57,39 +57,39 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解
点击下方提供商查看详细配置指南:
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/github'} title={'GitHub'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/github'} title={'GitHub'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/google'} title={'Google'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/google'} title={'Google'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/microsoft'} title={'Microsoft'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/microsoft'} title={'Microsoft'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/apple'} title={'Apple'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/apple'} title={'Apple'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/cognito'} title={'AWS Cognito'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/cognito'} title={'AWS Cognito'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/auth0'} title={'Auth0'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/authelia'} title={'Authelia'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/authentik'} title={'Authentik'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/casdoor'} title={'Casdoor'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/casdoor'} title={'Casdoor'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/cloudflare-zero-trust'} title={'Cloudflare Zero Trust'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/keycloak'} title={'Keycloak'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/logto'} title={'Logto'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/okta'} title={'Okta'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/okta'} title={'Okta'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/zitadel'} title={'ZITADEL'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/generic-oidc'} title={'Generic OIDC'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/feishu'} title={'飞书'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/feishu'} title={'飞书'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/better-auth/wechat'} title={'微信'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/providers/wechat'} title={'微信'} />
</Cards>
## 回调 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`(端口 465TLS 设置为 `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` 时使用 |
### 方式一NodemailerSMTP
使用 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`(端口 465TLS 设置为 `false`(端口 587 | `false` |
| `SMTP_USER` | 必选 | SMTP 认证用户名 | `user@gmail.com` |
| `SMTP_PASS` | 必选 | SMTP 认证密码 | `your-app-password`|
<Callout type={'warning'}>
使用 Gmail 时,需使用应用专用密码而非账户密码。前往 [Google 应用专用密码](https://myaccount.google.com/apppasswords) 生成。
</Callout>
### 方式二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` |
<Callout type={'info'}>
使用 Resend 前需先 [验证发件域名](https://resend.com/docs/dashboard/domains/introduction),否则只能发送到自己的邮箱。
</Callout>
### 通用配置
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------- | -- | ---------------------------- | -- |
| `AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱(默认关闭) | `1`|
## 魔法链接(免密)登录

View File

@@ -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.
<Callout type={'info'}>
Better Auth is the recommended authentication solution for LobeChat. It offers simpler configuration, more SSO providers, and better self-hosting support.
</Callout>
<Callout type={'error'}>
**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
</Callout>
## 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.
<Callout type={'warning'}>
**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.
</Callout>
![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
```
<Callout type={'tip'}>
See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup.
</Callout>
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
<Callout type={'tip'}>
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).
</Callout>
## Full Migration
For larger deployments or when you need to preserve user passwords and SSO connections, use the migration scripts.
<Callout type={'error'}>
**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
</Callout>
<Callout type={'warning'}>
**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
</Callout>
### 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
<Callout type={'info'}>
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
```
</Callout>
### 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**
<Callout type={'tip'}>
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth), including all supported SSO providers and email service configuration.
</Callout>
## 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
<Cards>
<Card href={'/docs/self-hosting/advanced/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/environment-variables/auth'} title={'Auth Environment Variables'} />
<Card href={'/docs/self-hosting/advanced/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
</Cards>

View File

@@ -0,0 +1,360 @@
---
title: 从 Clerk 迁移到 Better Auth
description: 将 LobeChat 部署从 Clerk 身份验证迁移到 Better Auth 的指南,包括简单迁移和完整迁移选项。
tags:
- 身份验证服务
- Better Auth
- Clerk
- 迁移
---
# 从 Clerk 迁移到 Better Auth
本指南帮助您将现有的基于 Clerk 的 LobeChat 部署迁移到 Better Auth。
<Callout type={'info'}>
Better Auth 是 LobeChat 推荐的身份验证解决方案。它提供更简单的配置、更多的 SSO 提供商支持,以及更好的自托管体验。
</Callout>
<Callout type={'error'}>
**重要提醒**
- **务必先备份数据库**!如使用 Neon可通过 [Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建备份
- 迁移过程中可能出现的任何数据丢失或问题LobeChat 概不负责
- 本指南适合有一定开发背景的用户,不建议无技术经验的用户自行操作
- 如有任何疑问,欢迎到 [Discord](https://discord.com/invite/AYFPHvv2jT) 社区提问
</Callout>
## 选择迁移方式
| 方式 | 适用场景 | 用户影响 | 数据保留 |
| ------------- | --------------- | ------- | -------- |
| [简单迁移](#简单迁移) | 小型部署(\< 100 用户) | 用户需重置密码 | 聊天记录、设置 |
| [完整迁移](#完整迁移) | 大型部署 | 对用户无感知 | 全部数据包括密码 |
## 简单迁移
对于小型自托管部署,最简单的方法是让用户重置密码。
<Callout type={'warning'}>
**限制**:此方法会丢失 SSO 连接数据。如需保留 SSO 连接,请使用 [完整迁移](#完整迁移)。
**示例场景**:假设你之前的账户绑定了两个 SSO 账户:
- 主邮箱Google`mail1@google.com`
- 副邮箱Microsoft`mail2@outlook.com`
迁移后使用 `mail1@google.com` 重置密码,之后再用 `mail2@outlook.com` 登录将会**创建新用户**,而非关联到原有账户。
</Callout>
![个人资料页 - 关联账号信息](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
```
<Callout type={'tip'}>
查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。
</Callout>
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. (可选)登录后可在个人资料页进行以下操作:
- 已关联账号:手动关联其他社交账号
- 密码:随时设置或更新密码
<Callout type={'tip'}>
这种方法快速且配置简单。用户可通过 Magic Link、社交账号或设置新密码登录所有数据完整保留。
登录后可随时在 [个人资料页](/settings/profile) 管理密码和关联账号。
</Callout>
## 完整迁移
对于大型部署或需要保留用户密码和 SSO 连接的情况,请使用迁移脚本。
<Callout type={'error'}>
**重要说明**
- 迁移脚本需要 **clone 仓库后在本地运行**,不是在部署环境中执行
- 由于迁移涉及用户数据,风险较高,**官方不提供部署时自动迁移功能**
- 请务必在测试环境验证后再操作生产数据库
</Callout>
<Callout type={'warning'}>
**迁移前准备**
- 使用 [Neon Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建测试数据库
- 使用 Clerk Development 环境的 API 密钥
- 先在 test 模式下验证,确认成功后再切换到 prod 模式
</Callout>
### 前置条件
**环境要求:**
- 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 为最新版本
<Callout type={'info'}>
如果你长期停留在旧版本(如 1.x数据库 schema 可能不是最新的。请在 clone 的仓库中运行:
```bash
DATABASE_URL=your-database-url pnpm db:migrate
```
</Callout>
### 步骤 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`,包含额外的用户数据。
### 步骤 3Dry-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
```
验证测试环境迁移结果无误后,继续下一步。
### 步骤 5Dry-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**
<Callout type={'tip'}>
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。
</Callout>
## 迁移内容对比
| 数据 | 简单迁移 | 完整迁移 |
| ----------------------- | --------- | ---- |
| 用户账户 | ✅(通过密码重置) | ✅ |
| 密码哈希 | ❌ | ✅ |
| 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` 更新数据库结构,然后再执行迁移脚本。
## 相关阅读
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/environment-variables/auth'} title={'认证相关环境变量'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/legacy'} title={'旧版身份验证NextAuth 和 Clerk'} />
</Cards>

View File

@@ -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).
</Callout>
<Callout type={'tip'}>
To migrate from Clerk to Better Auth, see the [Clerk Migration Guide](/docs/self-hosting/advanced/auth/clerk-to-betterauth).
</Callout>
## Next Auth
Before using NextAuth, please set the following variables in LobeChat's environment variables:

View File

@@ -28,6 +28,10 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供安全、便捷的
详细的 Clerk 配置请参阅 [Clerk 配置指南](/zh/docs/self-hosting/advanced/auth/clerk)。
</Callout>
<Callout type={'tip'}>
如需从 Clerk 迁移到 Better Auth请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/advanced/auth/clerk-to-betterauth)。
</Callout>
## Next Auth
在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量:

View File

@@ -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",

View File

@@ -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": "请输入邮箱地址",

View File

@@ -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', '']);
});
});

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -0,0 +1,6 @@
import { existsSync } from 'node:fs';
import { loadEnvFile } from 'node:process';
if (existsSync('.env')) {
loadEnvFile();
}

View File

@@ -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<string, string>[] {
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<string, string> = {};
headers.forEach((header, index) => {
record[header] = (values[index] ?? '').trim();
});
return record;
});
}
export async function loadCSVData(path = resolveDataPaths().clerkCsvPath): Promise<CSVUserRow[]> {
const csv = await readFile(path, 'utf8');
const jsonData = parseCsv(csv);
return jsonData as CSVUserRow[];
}
export async function loadClerkUsersFromFile(
path = resolveDataPaths().clerkUsersPath,
): Promise<ClerkUser[]> {
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}`);
}
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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<void>((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<typeof getClerkClient>,
pageIndex: number,
): Promise<ClerkUser[]> {
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<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const queue = [...items];
const inFlight: Promise<void>[] = [];
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<ClerkUser[]> {
const clerkClient = getClerkClient(secretKey);
const userMap = new Map<string, ClerkUser>();
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);
});

View File

@@ -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<T>(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<string>();
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<string, number> = {};
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<string>((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();

View File

@@ -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<ReturnType<typeof loadCSVData>>,
clerkUsers: ClerkUser[],
) {
const clerkMap = new Map(clerkUsers.map((u) => [u.id, u]));
const expectedAccounts: ExpectedAccount[] = [];
const expectedTwoFactorUsers = new Set<string>();
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<string, number> = {};
const actualProviderCounts: Record<string, number> = {};
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<string, number>) =>
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();
});

View File

@@ -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<void>;
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 = ({
/>
</Form.Item>
</Form>
{isSocialOnly && (
<Alert
description={
<>
{t('betterAuth.signin.socialOnlyHint')}{' '}
<a className={styles.setPasswordLink} onClick={onSetPassword}>
{t('betterAuth.signin.setPassword')}
</a>
</>
}
showIcon
style={{ marginTop: 12 }}
type="info"
/>
)}
</AuthCard>
);
};

View File

@@ -17,6 +17,7 @@ const SignInPage = () => {
handleForgotPassword,
handleSignIn,
handleSocialSignIn,
isSocialOnly,
loading,
oAuthSSOProviders,
serverConfigInit,
@@ -29,9 +30,11 @@ const SignInPage = () => {
{step === 'email' ? (
<SignInEmailStep
form={form as any}
isSocialOnly={isSocialOnly}
loading={loading}
oAuthSSOProviders={oAuthSSOProviders}
onCheckUser={handleCheckUser}
onSetPassword={handleForgotPassword}
onSocialSignIn={handleSocialSignIn}
serverConfigInit={serverConfigInit}
socialLoading={socialLoading}

View File

@@ -1,6 +1,5 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Form } from 'antd';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,6 +9,7 @@ import { useBusinessSignin } from '@/business/client/hooks/useBusinessSignin';
import { message } from '@/components/AntdStaticMethods';
import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client';
import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
@@ -37,6 +37,7 @@ export const useSignIn = () => {
const [socialLoading, setSocialLoading] = useState<string | null>(null);
const [step, setStep] = useState<Step>('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,

View File

@@ -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',