mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
📝 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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`(端口 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`|
|
||||
|
||||
<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`|
|
||||
|
||||
## 魔法链接(免密)登录
|
||||
|
||||
|
||||
366
docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx
Normal file
366
docs/self-hosting/advanced/auth/clerk-to-betterauth.mdx
Normal 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>
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
#### 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>
|
||||
360
docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx
Normal file
360
docs/self-hosting/advanced/auth/clerk-to-betterauth.zh-CN.mdx
Normal 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>
|
||||
|
||||

|
||||
|
||||
### 步骤
|
||||
|
||||
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)登录
|
||||
- 点击「设置密码」链接,通过邮件设置新密码后登录
|
||||
|
||||

|
||||
|
||||
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)。
|
||||
|
||||

|
||||
|
||||
#### 通过 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**
|
||||
|
||||
<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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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 的环境变量中设置以下变量:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "请输入邮箱地址",
|
||||
|
||||
21
scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts
Normal file
21
scripts/clerk-to-betterauth/__tests__/parseCsvLine.test.ts
Normal 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', '']);
|
||||
});
|
||||
});
|
||||
55
scripts/clerk-to-betterauth/_internal/config.ts
Normal file
55
scripts/clerk-to-betterauth/_internal/config.ts
Normal 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;
|
||||
}
|
||||
32
scripts/clerk-to-betterauth/_internal/db.ts
Normal file
32
scripts/clerk-to-betterauth/_internal/db.ts
Normal 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';
|
||||
6
scripts/clerk-to-betterauth/_internal/env.ts
Normal file
6
scripts/clerk-to-betterauth/_internal/env.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
|
||||
if (existsSync('.env')) {
|
||||
loadEnvFile();
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
45
scripts/clerk-to-betterauth/_internal/types.ts
Normal file
45
scripts/clerk-to-betterauth/_internal/types.ts
Normal 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;
|
||||
};
|
||||
36
scripts/clerk-to-betterauth/_internal/utils.ts
Normal file
36
scripts/clerk-to-betterauth/_internal/utils.ts
Normal 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;
|
||||
}
|
||||
211
scripts/clerk-to-betterauth/export-clerk-users-with-api.ts
Normal file
211
scripts/clerk-to-betterauth/export-clerk-users-with-api.ts
Normal 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);
|
||||
});
|
||||
314
scripts/clerk-to-betterauth/index.ts
Normal file
314
scripts/clerk-to-betterauth/index.ts
Normal 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();
|
||||
275
scripts/clerk-to-betterauth/verify.ts
Normal file
275
scripts/clerk-to-betterauth/verify.ts
Normal 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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user