Compare commits

...

24 Commits

Author SHA1 Message Date
YuTengjing bfe02d8097 feat(auth): improve email verification flow with 403 redirect and resend feature
- Redirect to verify-email page when signin fails with 403 (unverified email)
- Add resend verification email button on verify-email page
- Export sendVerificationEmail from auth-client
- Add i18n translations for resend feature
2025-11-13 18:48:09 +08:00
YuTengjing 3eba40de4e feat(auth): redesign email verification template with better UX
- Define VERIFICATION_LINK_EXPIRES_IN constant and pass to template
- Redesign email template with modern, clean aesthetic
- Replace black logo card with simple text for better visual hierarchy
- Change CTA button from black to blue gradient for better prominence
- Add blue glow shadow to CTA button for emphasis
- Restructure information hierarchy with collapsible alternative link
- Add security notice with blue accent card
- Improve mobile responsiveness and readability
- Dynamic expiration time formatting (hours/minutes)
2025-11-13 17:50:19 +08:00
YuTengjing a67b0e605c feat(auth): improve password validation and verification UX
- Change password max length from 11 to 64 characters
- Add password format validation (must contain both letters and numbers)
- Update password placeholder to show requirements
- Remove redundant success message after signup
- Make email address clickable with mailto link in verification page
- Replace prominent button with subtle back link in verification page
2025-11-13 17:50:19 +08:00
YuTengjing 8b9e8c3a8e feat(auth): simplify signup form by removing unnecessary fields
- Remove firstName, lastName, and username fields
- Only require email and password for registration
- Auto-generate username from email address (part before @)
- Remove additionalFields config from Better Auth
2025-11-13 17:50:19 +08:00
YuTengjing 60ceab08d9 feat(auth): auto-redirect to signup with email pre-fill
- Redirect to signup page when user doesn't exist (no error message)
- Pre-fill email in signup form from URL params
- Pass email from signin page to signup via URL params
2025-11-13 17:50:19 +08:00
YuTengjing 85e5a40666 feat(auth): auto-focus input on signin step transitions
Auto-focus email input when entering email step and password input when entering password step to improve user experience.
2025-11-13 17:50:19 +08:00
YuTengjing a1b741d94d 🐛 fix(auth): correct Better Auth signOut redirect behavior
Fix the issue where logout was incorrectly redirecting to /login instead
of /signin page for Better Auth.

Changes:
- Update signOut in auth store to use Better Auth's fetchOptions.onSuccess
  callback for proper redirect handling after signOut completes
- Add comment explaining why window.location.href is used instead of
  router.push (to trigger full page reload and clear all client state)
- Update PanelContent to let NextAuth and Better Auth handle redirects
  in their own signOut methods, only Clerk needs manual redirect to /login

This ensures:
1. Better Auth correctly redirects to /signin after logout
2. Full page reload clears all client-side state for security
3. Each auth provider handles its own redirect logic consistently
2025-11-13 17:50:18 +08:00
YuTengjing fbf76c8b8f feat(auth): improve signup flow with auto-login and better redirect handling
- Enable autoSignInAfterVerification in Better Auth config so users are
  automatically logged in after verifying their email
- Update signup page to use Better Auth's callbackURL parameter for proper
  post-verification redirection
- Fix redirect logic when email verification is not required:
  - Users are now auto-logged in (autoSignIn: true) and redirected to
    callbackUrl or / directly, instead of being sent to signin page
- Pass callbackUrl through the entire flow (signup → verify-email → callback)
  to ensure users land on the intended page after completing registration

This improves UX by:
1. Eliminating unnecessary signin step when verification is disabled
2. Auto-logging users in after email verification
3. Properly redirecting users to their intended destination
2025-11-13 17:50:18 +08:00
YuTengjing 848f527abd feat(auth): add environment variable to control email verification requirement
Add NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION environment variable to
allow developers to choose whether to enforce email verification. Default value
is false (email verification not required).

Changes:
- Add NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION to src/envs/auth.ts
- Update src/auth.ts to use environment variable for requireEmailVerification
- Update .env.example with documentation for the new environment variable
- Optimize signup flow to conditionally redirect based on email verification setting
  - If email verification required: redirect to /verify-email page
  - If email verification not required: redirect to /signin page

This allows developers to disable email verification requirement while keeping
the email verification functionality available for those who want to use it.
2025-11-13 17:50:18 +08:00
YuTengjing 0a56fc05e7 feat(auth): implement Better Auth email/password signin with two-step flow
- Add signin page with two-step authentication flow (email → password)
- Create API endpoint to check user existence before signin
- Add Better Auth session support in tRPC context and middleware
- Update auth store actions to support Better Auth login/logout
- Export signOut from Better Auth client
- Add i18n translations for signin flow (en-US, zh-CN)

This resolves the 401 Unauthorized issue after successful signin by properly
integrating Better Auth session validation into the tRPC authentication flow.
2025-11-13 17:50:18 +08:00
YuTengjing f61eb76ccf 💄 style(auth): improve signup and verify-email pages UI and fix layout issues
- Fix antd message context consumption warning in signup form
- Center logo properly in verify-email page
- Reduce vertical spacing between description and hint text
- Fix email icon background box width by making it circular
- Modernize verify-email page design with gradient background and better typography
- Use primary color theme for email icon
- Improve overall layout and visual hierarchy
2025-11-13 17:50:18 +08:00
YuTengjing 9b2298e796 ♻️ refactor(auth): remove unused headers from BetterAuth middleware 2025-11-13 17:50:17 +08:00
YuTengjing 8e3ab6d980 feat(auth): implement BetterAuth middleware with full session support
- Add betterAuthMiddleware using auth.api.getSession() for Next.js 16+
- Enable Node.js runtime in middleware config for session validation
- Support OIDC session pre-sync with userId header
- Add /signin route to public routes and matcher config
- Update middleware priority: Clerk > BetterAuth > NextAuth > Default
- Implement protected route redirection to /signin with callbackUrl
2025-11-13 17:50:17 +08:00
YuTengjing efc0ca8ca2 🐛 fix(auth): enable access to verify-email page and export betterAuth schema
- Add /verify-email to middleware matcher config
- Add /verify-email to public routes
- Export betterAuth schema from database schemas index
2025-11-13 17:50:17 +08:00
YuTengjing e22cf007bf feat(auth): improve signup form with firstName/lastName fields
- Merge SignUpForm component into page for simpler structure
- Add firstName and lastName as required fields (displayed side-by-side)
- Reorder form fields: firstName/lastName → username → email → password
- Configure additional fields in better-auth with proper type inference
- Map username to better-auth's name field (stored in database username column)
- Add type-safe plugin (inferAdditionalFields) for client-side field definitions
- Update i18n translations for new fields (zh-CN, en-US)
2025-11-13 17:50:17 +08:00
YuTengjing b1c570bf80 🐛 fix(auth): include BetterAuth in enableAuth check
- Add enableBetterAuth constant to packages/const/src/auth.ts
- Include BetterAuth in enableAuth calculation to properly track auth state
- Add isLoginWithBetterAuth selector for consistency with other auth providers
- Fixes unauthorized API calls on signup page when BetterAuth is enabled
2025-11-13 17:50:16 +08:00
YuTengjing 96040dc3bf feat(auth): implement BetterAuth provider integration
- Create BetterAuth AuthProvider with UserUpdater component
- Sync session state to Zustand store using useSession hook
- Add NEXT_PUBLIC_ENABLE_BETTER_AUTH environment flag
- Use NEXT_PUBLIC_BETTER_AUTH_URL for client-side auth API access
- Remove unnecessary server-side BETTER_AUTH_URL configuration
- Update environment variable examples
2025-11-13 17:50:16 +08:00
YuTengjing b2c22a3df6 ♻️ refactor(email): centralize SMTP configuration in dedicated env file
- Create src/envs/email.ts for centralized email service environment variables
- Update email service imports to use emailEnv instead of process.env directly
- Replace || with ?? operator for proper nullish coalescing in Nodemailer config
2025-11-13 17:50:16 +08:00
YuTengjing 5869bbacf3 feat(auth): implement better-auth signup with email verification
- Add signup page with email/username/password form
- Add email verification notice page
- Add i18n translations (zh-CN, en-US)
- Delete Clerk login and signup pages
- Integrate with better-auth email verification flow
2025-11-13 17:50:16 +08:00
YuTengjing f0f21a6b0d feat(auth): integrate email verification with better-auth
- Add email templates for verification and password reset
- Make EmailPayload.from optional, default to SMTP_USER
- Validate SMTP_USER configuration in EmailService
- Implement sendVerificationEmail and sendResetPassword callbacks
- Add SMTP configuration to .env.example
- Support both specific from address and default SMTP_USER
2025-11-13 17:50:15 +08:00
YuTengjing 0ce3fa32b4 feat(database): add better-auth database migration
Add database migration for better-auth authentication system:
- Create accounts table for OAuth providers
- Create auth_sessions table for session management
- Create verifications table for email verification
- Add email_verified column to users table

Optimize migration file naming (0046_puzzling_electro → 0046_better_auth)
2025-11-13 17:50:15 +08:00
YuTengjing 599eb0187a feat(email): add email service with nodemailer support
- Implement unified email service abstraction based on search service pattern
- Add nodemailer provider implementation with SMTP support
- Support multiple recipients, attachments, and reply-to addresses
- Include comprehensive unit tests (8 tests passing)
- Add usage documentation and integration examples
2025-11-13 17:50:14 +08:00
YuTengjing 5eaf34472c ♻️ refactor(auth): remove legacy next-auth route
Replace with better-auth API route handler
2025-11-13 17:50:14 +08:00
YuTengjing 0a7eee86f2 ♻️ refactor(auth): migrate to better-auth authentication system
- Add better-auth dependency for improved authentication
- Create database schema and models for better-auth integration
- Add auth configuration and API route handlers
- Export db-adaptor from database package
- Update environment configuration for auth setup
2025-11-13 17:50:14 +08:00
50 changed files with 10288 additions and 105 deletions
+16 -1
View File
@@ -5,7 +5,22 @@ alwaysApply: false
# Database Migrations Guide
## Defensive Programming - Use Idempotent Clauses
## Step1: Generate migrations:
```bash
bun run db:generate
```
this step will generate or update the following files:
- packages/database/migrations/0046_xxx.sql
- packages/database/migrations/meta/\_journal.json
## Step2: optimize the migration sql fileName
the migration sql file name is randomly generated, we need to optimize the file name to make it more readable and meaningful. For example, `0046_xxx.sql` -> `0046_better_auth.sql`
## Step3: Defensive Programming - Use Idempotent Clauses
Always use defensive clauses to make migrations idempotent:
+34
View File
@@ -280,6 +280,40 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# AUTH_AUTH0_SECRET=
# AUTH_AUTH0_ISSUER=https://your-domain.auth0.com
# Better-Auth related configurations
# NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
# Better-Auth Secret (use `openssl rand -base64 32` to generate)
# BETTER_AUTH_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Better-Auth URL (accessible from browser, optional if same domain)
# NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3210
# Require email verification before allowing users to sign in (default: false)
# Set to '1' to force users to verify their email before signing in
# NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=0
########################################
########### Email Service ##############
########################################
# SMTP Server Configuration (required for email verification with Better-Auth)
# SMTP server hostname (e.g., smtp.gmail.com, smtp.office365.com)
# SMTP_HOST=smtp.example.com
# SMTP server port (usually 587 for TLS, or 465 for SSL)
# SMTP_PORT=587
# Use secure connection (set to 'true' for port 465, 'false' for port 587)
# SMTP_SECURE=false
# SMTP authentication username (usually your email address)
# SMTP_USER=your-email@example.com
# SMTP authentication password (use app-specific password for Gmail)
# SMTP_PASS=your-password-or-app-specific-password
########################################
########## Server Database #############
########################################
+1
View File
@@ -974,6 +974,7 @@ table users {
full_name text
is_onboarded boolean [default: false]
clerk_created_at "timestamp with time zone"
email_verified boolean [not null, default: false]
email_verified_at "timestamp with time zone"
preference jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
+51
View File
@@ -52,6 +52,57 @@
"required": "This field cannot be empty"
}
},
"betterAuth": {
"errors": {
"emailInvalid": "Please enter a valid email address",
"emailNotRegistered": "This email is not registered",
"emailNotVerified": "Email not verified, please verify your email first",
"emailRequired": "Please enter your email address",
"firstNameRequired": "Please enter your first name",
"lastNameRequired": "Please enter your last name",
"loginFailed": "Login failed, please check your email and password",
"passwordMinLength": "Password must be at least 8 characters",
"passwordRequired": "Please enter your password",
"usernameRequired": "Please enter your username"
},
"signin": {
"backToEmail": "Back to change email",
"emailPlaceholder": "Enter your email address",
"emailStep": {
"subtitle": "Enter your email address to continue",
"title": "Sign In"
},
"error": "Sign in failed, please check your email and password",
"nextStep": "Next",
"noAccount": "Don't have an account?",
"passwordPlaceholder": "Enter your password",
"passwordStep": {
"subtitle": "Enter your password to continue"
},
"signupLink": "Sign up now",
"submit": "Sign In"
},
"signup": {
"emailPlaceholder": "Enter your email address",
"error": "Sign up failed, please try again",
"firstNamePlaceholder": "First Name",
"hasAccount": "Already have an account?",
"lastNamePlaceholder": "Last Name",
"passwordPlaceholder": "Enter your password (at least 8 characters)",
"signinLink": "Sign in now",
"submit": "Sign Up",
"success": "Sign up successful! Please check your email for verification",
"subtitle": "Join LobeChat Community",
"title": "Create Account",
"usernamePlaceholder": "Enter your username"
},
"verifyEmail": {
"backToSignIn": "Back to Sign In",
"checkSpam": "If you don't receive the email, please check your spam folder",
"description": "We've sent a verification email to {{email}}",
"title": "Verify Your Email"
}
},
"date": {
"prevMonth": "Last Month",
"recent30Days": "Last 30 Days"
+51
View File
@@ -52,6 +52,57 @@
"required": "内容不得为空"
}
},
"betterAuth": {
"errors": {
"emailInvalid": "请输入有效的邮箱地址",
"emailNotRegistered": "该邮箱尚未注册",
"emailNotVerified": "邮箱尚未验证,请先验证邮箱",
"emailRequired": "请输入邮箱地址",
"firstNameRequired": "请输入名字",
"lastNameRequired": "请输入姓氏",
"loginFailed": "登录失败,请检查邮箱和密码",
"passwordMinLength": "密码至少需要 8 个字符",
"passwordRequired": "请输入密码",
"usernameRequired": "请输入用户名"
},
"signin": {
"backToEmail": "返回修改邮箱",
"emailPlaceholder": "请输入邮箱地址",
"emailStep": {
"subtitle": "请输入您的邮箱地址以继续",
"title": "登录"
},
"error": "登录失败,请检查邮箱和密码",
"nextStep": "下一步",
"noAccount": "还没有账号?",
"passwordPlaceholder": "请输入密码",
"passwordStep": {
"subtitle": "请输入密码以继续"
},
"signupLink": "立即注册",
"submit": "登录"
},
"signup": {
"emailPlaceholder": "请输入邮箱地址",
"error": "注册失败,请重试",
"firstNamePlaceholder": "名字",
"hasAccount": "已有账号?",
"lastNamePlaceholder": "姓氏",
"passwordPlaceholder": "请输入密码(至少 8 个字符)",
"signinLink": "立即登录",
"submit": "注册",
"success": "注册成功!请检查您的邮箱验证邮件",
"subtitle": "加入 LobeChat 社区",
"title": "创建账号",
"usernamePlaceholder": "请输入用户名"
},
"verifyEmail": {
"backToSignIn": "返回登录",
"checkSpam": "如果没有收到邮件,请检查垃圾邮件文件夹",
"description": "我们已向 {{email}} 发送了验证邮件",
"title": "验证您的邮箱"
}
},
"date": {
"prevMonth": "上个月",
"recent30Days": "最近30天"
+3
View File
@@ -198,6 +198,7 @@
"ahooks": "^3.9.6",
"antd": "^5.28.1",
"antd-style": "^3.7.1",
"better-auth": "^1.3.34",
"brotli-wasm": "^3.0.1",
"chroma-js": "^3.1.2",
"cookie": "^1.0.2",
@@ -241,6 +242,7 @@
"next-mdx-remote": "^5.0.0",
"nextjs-toploader": "^3.9.17",
"node-machine-id": "^1.1.12",
"nodemailer": "^7.0.10",
"numeral": "^2.0.6",
"nuqs": "^2.7.3",
"officeparser": "5.1.1",
@@ -332,6 +334,7 @@
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
"@types/nodemailer": "^7.0.3",
"@types/numeral": "^2.0.5",
"@types/oidc-provider": "^9.5.0",
"@types/pdfkit": "^0.17.3",
+2 -1
View File
@@ -1,6 +1,7 @@
export const enableClerk = !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
export const enableBetterAuth = process.env.NEXT_PUBLIC_ENABLE_BETTER_AUTH === '1';
export const enableNextAuth = process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1';
export const enableAuth = enableClerk || enableNextAuth || false;
export const enableAuth = enableClerk || enableBetterAuth || enableNextAuth || false;
export const LOBE_CHAT_AUTH_HEADER = 'X-lobe-chat-auth';
export const LOBE_CHAT_OIDC_AUTH_HEADER = 'Oidc-Auth';
@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS "accounts" (
"access_token" text,
"access_token_expires_at" timestamp,
"account_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"id" text PRIMARY KEY NOT NULL,
"id_token" text,
"password" text,
"provider_id" text NOT NULL,
"refresh_token" text,
"refresh_token_expires_at" timestamp,
"scope" text,
"updated_at" timestamp NOT NULL,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "auth_sessions" (
"created_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp NOT NULL,
"id" text PRIMARY KEY NOT NULL,
"ip_address" text,
"token" text NOT NULL,
"updated_at" timestamp NOT NULL,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "auth_sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "verifications" (
"created_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp NOT NULL,
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"value" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "email_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -322,6 +322,13 @@
"when": 1762911968658,
"tag": "0045_add_tool_intervention",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1762916406092,
"tag": "0046_better_auth",
"breakpoints": true
}
],
"version": "6"
+22 -26
View File
@@ -223,10 +223,7 @@
"hash": "9646161fa041354714f823d726af27247bcd6e60fa3be5698c0d69f337a5700b"
},
{
"sql": [
"DROP TABLE \"user_budgets\";",
"\nDROP TABLE \"user_subscriptions\";"
],
"sql": ["DROP TABLE \"user_budgets\";", "\nDROP TABLE \"user_subscriptions\";"],
"bps": true,
"folderMillis": 1729699958471,
"hash": "7dad43a2a25d1aec82124a4e53f8d82f8505c3073f23606c1dc5d2a4598eacf9"
@@ -298,9 +295,7 @@
"hash": "845a692ceabbfc3caf252a97d3e19a213bc0c433df2689900135f9cfded2cf49"
},
{
"sql": [
"ALTER TABLE \"messages\" ADD COLUMN \"reasoning\" jsonb;"
],
"sql": ["ALTER TABLE \"messages\" ADD COLUMN \"reasoning\" jsonb;"],
"bps": true,
"folderMillis": 1737609172353,
"hash": "2cb36ae4fcdd7b7064767e04bfbb36ae34518ff4bb1b39006f2dd394d1893868"
@@ -515,9 +510,7 @@
"hash": "a7ccf007fd185ff922823148d1eae6fafe652fc98d2fd2793f84a84f29e93cd1"
},
{
"sql": [
"ALTER TABLE \"ai_providers\" ADD COLUMN \"config\" jsonb;"
],
"sql": ["ALTER TABLE \"ai_providers\" ADD COLUMN \"config\" jsonb;"],
"bps": true,
"folderMillis": 1749309388370,
"hash": "39cea379f08ee4cb944875c0b67f7791387b508c2d47958bb4cd501ed1ef33eb"
@@ -635,9 +628,7 @@
"hash": "1ba9b1f74ea13348da98d6fcdad7867ab4316ed565bf75d84d160c526cdac14b"
},
{
"sql": [
"ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"virtual\" boolean DEFAULT false;"
],
"sql": ["ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"virtual\" boolean DEFAULT false;"],
"bps": true,
"folderMillis": 1759116400580,
"hash": "433ddae88e785f2db734e49a4c115eee93e60afe389f7919d66e5ba9aa159a37"
@@ -687,17 +678,13 @@
"hash": "4bdc6505797d7a33b622498c138cfd47f637239f6905e1c484cd01d9d5f21d6b"
},
{
"sql": [
"ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"image\" jsonb;"
],
"sql": ["ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"image\" jsonb;"],
"bps": true,
"folderMillis": 1760108430562,
"hash": "ce09b301abb80f6563abc2f526bdd20b4f69bae430f09ba2179b9e3bfec43067"
},
{
"sql": [
"ALTER TABLE \"documents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"
],
"sql": ["ALTER TABLE \"documents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"],
"bps": true,
"folderMillis": 1761554153406,
"hash": "bf2f21293e90e11cf60a784cf3ec219eafa95f7545d7d2f9d1449c0b0949599a"
@@ -777,19 +764,28 @@
"hash": "923ccbdf46c32be9a981dabd348e6923b4a365444241e9b8cc174bf5b914cbc5"
},
{
"sql": [
"ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"market_identifier\" text;\n"
],
"sql": ["ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"market_identifier\" text;\n"],
"bps": true,
"folderMillis": 1762870034882,
"hash": "4178aacb4b8892b7fd15d29209bbf9b1d1f9d7c406ba796f27542c0bcd919680"
},
{
"sql": [
"ALTER TABLE \"message_plugins\" ADD COLUMN IF NOT EXISTS \"intervention\" jsonb;\n"
],
"sql": ["ALTER TABLE \"message_plugins\" ADD COLUMN IF NOT EXISTS \"intervention\" jsonb;\n"],
"bps": true,
"folderMillis": 1762911968658,
"hash": "552a032cc0e595277232e70b5f9338658585bafe9481ae8346a5f322b673a68b"
},
{
"sql": [
"CREATE TABLE IF NOT EXISTS \"accounts\" (\n\t\"access_token\" text,\n\t\"access_token_expires_at\" timestamp,\n\t\"account_id\" text NOT NULL,\n\t\"created_at\" timestamp DEFAULT now() NOT NULL,\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"id_token\" text,\n\t\"password\" text,\n\t\"provider_id\" text NOT NULL,\n\t\"refresh_token\" text,\n\t\"refresh_token_expires_at\" timestamp,\n\t\"scope\" text,\n\t\"updated_at\" timestamp NOT NULL,\n\t\"user_id\" text NOT NULL\n);\n",
"\nCREATE TABLE IF NOT EXISTS \"auth_sessions\" (\n\t\"created_at\" timestamp DEFAULT now() NOT NULL,\n\t\"expires_at\" timestamp NOT NULL,\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"ip_address\" text,\n\t\"token\" text NOT NULL,\n\t\"updated_at\" timestamp NOT NULL,\n\t\"user_agent\" text,\n\t\"user_id\" text NOT NULL,\n\tCONSTRAINT \"auth_sessions_token_unique\" UNIQUE(\"token\")\n);\n",
"\nCREATE TABLE IF NOT EXISTS \"verifications\" (\n\t\"created_at\" timestamp DEFAULT now() NOT NULL,\n\t\"expires_at\" timestamp NOT NULL,\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"identifier\" text NOT NULL,\n\t\"updated_at\" timestamp DEFAULT now() NOT NULL,\n\t\"value\" text NOT NULL\n);\n",
"\nALTER TABLE \"users\" ADD COLUMN IF NOT EXISTS \"email_verified\" boolean DEFAULT false NOT NULL;",
"\nALTER TABLE \"accounts\" ADD CONSTRAINT \"accounts_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
"\nALTER TABLE \"auth_sessions\" ADD CONSTRAINT \"auth_sessions_user_id_users_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;"
],
"bps": true,
"folderMillis": 1762916406092,
"hash": "3eff044bc672e64a3d18cdc669c3edc83da7450e38937c68d2776b793f4e8a09"
}
]
]
+1
View File
@@ -1 +1,2 @@
export * from './core/db-adaptor';
export * from './type';
@@ -1,9 +1,8 @@
import { DEFAULT_AGENT_CONFIG } from '@lobechat/const';
import { and, eq, inArray } from 'drizzle-orm';
import { LLMParams } from 'model-bank';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import {
NewSession,
SessionItem,
@@ -0,0 +1,63 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { users } from './user';
// export const user = pgTable('betterauth_user', {
// createdAt: timestamp('created_at').defaultNow().notNull(),
// email: text('email').notNull().unique(),
// emailVerified: boolean('email_verified').default(false).notNull(),
// id: text('id').primaryKey(),
// image: text('image'),
// name: text('name').notNull(),
// updatedAt: timestamp('updated_at')
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// });
export const session = pgTable('auth_sessions', {
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(),
id: text('id').primaryKey(),
ipAddress: text('ip_address'),
token: text('token').notNull().unique(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
});
export const account = pgTable('accounts', {
accessToken: text('access_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
accountId: text('account_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
id: text('id').primaryKey(),
idToken: text('id_token'),
password: text('password'),
providerId: text('provider_id').notNull(),
refreshToken: text('refresh_token'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
});
export const verification = pgTable('verifications', {
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(),
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
value: text('value').notNull(),
});
+1
View File
@@ -2,6 +2,7 @@ export * from './agent';
export * from './aiInfra';
export * from './apiKey';
export * from './asyncTask';
export * from './betterAuth';
export * from './chatGroup';
export * from './document';
export * from './file';
+1 -2
View File
@@ -1,9 +1,8 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { DEFAULT_MODEL } from '@lobechat/const';
import { EvalEvaluationStatus } from '@lobechat/types';
import { integer, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { DEFAULT_MODEL } from '@/const/settings';
import { timestamps } from './_helpers';
import { knowledgeBases } from './file';
import { embeddings } from './rag';
+3 -1
View File
@@ -1,8 +1,8 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { DEFAULT_PREFERENCE } from '@lobechat/const';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { boolean, jsonb, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { DEFAULT_PREFERENCE } from '@/const/user';
import { CustomPluginParams } from '@/types/tool/plugin';
import { timestamps, timestamptz } from './_helpers';
@@ -22,6 +22,8 @@ export const users = pgTable('users', {
// Time user was created in Clerk
clerkCreatedAt: timestamptz('clerk_created_at'),
// Required by better-auth
emailVerified: boolean('email_verified').default(false).notNull(),
// Required by nextauth, all null allowed
emailVerifiedAt: timestamptz('email_verified_at'),
@@ -1,9 +1,9 @@
import { INBOX_SESSION_ID } from '@lobechat/const';
import { TRPCError } from '@trpc/server';
import dayjs from 'dayjs';
import { count, eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { INBOX_SESSION_ID } from '@/const/session';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { UserGuide, UserPreference } from '@/types/user';
@@ -0,0 +1,5 @@
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/auth';
export const { POST, GET } = toNextJsHandler(auth);
@@ -1,3 +0,0 @@
import NextAuthNode from '@/libs/next-auth';
export const { GET, POST } = NextAuthNode.handlers;
@@ -0,0 +1,45 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { users } from '@/database/schemas/user';
import { serverDB } from '@/database/server';
/**
* Check if a user exists by email
* @param req - POST request with { email: string }
* @returns { exists: boolean, emailVerified?: boolean }
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email } = body;
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email is required', exists: false }, { status: 400 });
}
// Query database for user with this email
const [user] = await serverDB
.select({
emailVerified: users.emailVerified,
id: users.id,
})
.from(users)
.where(eq(users.email, email.toLowerCase().trim()))
.limit(1);
if (!user) {
return NextResponse.json({ exists: false });
}
return NextResponse.json({
emailVerified: user.emailVerified,
exists: true,
});
} catch (error) {
console.error('Error checking user existence:', error);
return NextResponse.json({ error: 'Internal server error', exists: false }, { status: 500 });
}
}
export const runtime = 'nodejs';
@@ -1,29 +0,0 @@
import { SignIn } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { enableClerk } from '@/const/auth';
import { BRANDING_NAME } from '@/const/branding';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const locale = await RouteVariants.getLocale(props);
const { t } = await translation('clerk', locale);
return metadataModule.generate({
description: t('signIn.start.subtitle'),
title: t('signIn.start.title', { applicationName: BRANDING_NAME }),
url: '/login',
});
};
const Page = () => {
if (!enableClerk) return notFound();
return <SignIn path="/login" />;
};
Page.displayName = 'Login';
export default Page;
+297
View File
@@ -0,0 +1,297 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { Form, Input, type InputRef } from 'antd';
import { createStyles, useTheme } from 'antd-style';
import { ChevronLeft, ChevronRight, Lock, Mail } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { message } from '@/components/AntdStaticMethods';
import { signIn } from '@/libs/better-auth/auth-client';
const useStyles = createStyles(({ css, token }) => ({
backButton: css`
cursor: pointer;
font-size: 14px;
color: ${token.colorPrimary};
&:hover {
color: ${token.colorPrimaryHover};
}
`,
card: css`
padding-block: 2.5rem;
padding-inline: 2rem;
`,
container: css`
width: 360px;
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
`,
emailDisplay: css`
font-size: 14px;
color: ${token.colorTextSecondary};
text-align: center;
`,
footer: css`
padding: 1rem;
border-block-start: 1px solid ${token.colorBorder};
font-size: 14px;
color: ${token.colorTextDescription};
text-align: center;
background: ${token.colorBgElevated};
`,
subtitle: css`
margin-block-start: 0.5rem;
font-size: 14px;
color: ${token.colorTextSecondary};
text-align: center;
`,
title: css`
margin-block-start: 1rem;
font-size: 24px;
font-weight: 600;
color: ${token.colorTextHeading};
text-align: center;
`,
}));
type Step = 'email' | 'password';
interface SignInFormValues {
email: string;
password: string;
}
export default function SignInPage() {
const { styles } = useStyles();
const theme = useTheme();
const { t } = useTranslation('auth');
const router = useRouter();
const searchParams = useSearchParams();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const emailInputRef = useRef<InputRef>(null);
const passwordInputRef = useRef<InputRef>(null);
// Auto-focus input when step changes
useEffect(() => {
if (step === 'email') {
emailInputRef.current?.focus();
} else if (step === 'password') {
passwordInputRef.current?.focus();
}
}, [step]);
// Check if user exists
const handleCheckUser = async (values: Pick<SignInFormValues, 'email'>) => {
setLoading(true);
try {
const response = await fetch('/api/auth/check-user', {
body: JSON.stringify({ email: values.email }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
const data = await response.json();
if (!data.exists) {
// User not found, redirect to signup page with email pre-filled
const callbackUrl = searchParams.get('callbackUrl') || '/';
router.push(
`/signup?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
);
return;
}
// User exists, move to password step
setEmail(values.email);
setStep('password');
} catch (error) {
console.error('Error checking user:', error);
message.error(t('betterAuth.signin.error'));
} finally {
setLoading(false);
}
};
// Sign in with email and password
const handleSignIn = async (values: SignInFormValues) => {
setLoading(true);
try {
const callbackUrl = searchParams.get('callbackUrl') || '/';
const result = await signIn.email(
{
callbackURL: callbackUrl,
email: email,
password: values.password,
},
{
onError: (ctx) => {
console.error('Sign in error:', ctx.error);
// Check if error is due to unverified email (403 status)
if (ctx.error.status === 403) {
// Redirect to verify-email page instead of showing error
router.push(
`/verify-email?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
);
return;
}
},
onSuccess: () => {
router.push(callbackUrl);
},
},
);
// Only show error if not already handled in onError callback
if (result.error && result.error.status !== 403) {
message.error(result.error.message || t('betterAuth.signin.error'));
return;
}
} catch (error) {
console.error('Sign in error:', error);
message.error(t('betterAuth.signin.error'));
} finally {
setLoading(false);
}
};
const handleBackToEmail = () => {
setStep('email');
setEmail('');
};
const handleGoToSignup = () => {
const currentEmail = form.getFieldValue('email');
const callbackUrl = searchParams.get('callbackUrl') || '/';
const params = new URLSearchParams();
if (currentEmail) {
params.set('email', currentEmail);
}
params.set('callbackUrl', callbackUrl);
router.push(`/signup?${params.toString()}`);
};
return (
<Flexbox align="center" justify="center" style={{ minHeight: '100vh' }}>
<div className={styles.container}>
<div className={styles.card}>
<Flexbox align="center" gap={8} justify="center">
<LobeHub size={48} />
</Flexbox>
<h1 className={styles.title}>{t('betterAuth.signin.emailStep.title')}</h1>
{step === 'email' && (
<>
<p className={styles.subtitle}>{t('betterAuth.signin.emailStep.subtitle')}</p>
<Form
form={form}
layout="vertical"
onFinish={handleCheckUser}
style={{ marginTop: '2rem' }}
>
<Form.Item
name="email"
rules={[
{ message: t('betterAuth.errors.emailRequired'), required: true },
{ message: t('betterAuth.errors.emailInvalid'), type: 'email' },
]}
style={{ marginBottom: 0 }}
>
<Input
placeholder={t('betterAuth.signin.emailPlaceholder')}
prefix={<Mail size={16} />}
ref={emailInputRef}
size="large"
suffix={
<ActionIcon
active
icon={ChevronRight}
loading={loading}
onClick={() => form.submit()}
size={{ blockSize: 32, size: 16 }}
style={{ color: theme.colorPrimary }}
title={t('betterAuth.signin.nextStep')}
/>
}
/>
</Form.Item>
</Form>
</>
)}
{step === 'password' && (
<>
<p className={styles.emailDisplay}>{email}</p>
<div
className={styles.backButton}
onClick={handleBackToEmail}
style={{ marginTop: '0.5rem', textAlign: 'center' }}
>
<ChevronLeft size={14} style={{ display: 'inline', verticalAlign: 'middle' }} />
<span style={{ marginLeft: '0.25rem' }}>{t('betterAuth.signin.backToEmail')}</span>
</div>
<p className={styles.subtitle} style={{ marginTop: '1rem' }}>
{t('betterAuth.signin.passwordStep.subtitle')}
</p>
<Form
form={form}
layout="vertical"
onFinish={handleSignIn}
style={{ marginTop: '1.5rem' }}
>
<Form.Item
name="password"
rules={[{ message: t('betterAuth.errors.passwordRequired'), required: true }]}
style={{ marginBottom: 0 }}
>
<Input.Password
placeholder={t('betterAuth.signin.passwordPlaceholder')}
prefix={<Lock size={16} />}
ref={passwordInputRef}
size="large"
suffix={
<ActionIcon
active
icon={ChevronRight}
loading={loading}
onClick={() => form.submit()}
size={{ blockSize: 32, size: 16 }}
style={{ color: theme.colorPrimary }}
title={t('betterAuth.signin.submit')}
/>
}
/>
</Form.Item>
</Form>
</>
)}
</div>
<div className={styles.footer}>
{t('betterAuth.signin.noAccount')}{' '}
<a
onClick={handleGoToSignup}
style={{ color: 'inherit', cursor: 'pointer', textDecoration: 'underline' }}
>
{t('betterAuth.signin.signupLink')}
</a>
</div>
</div>
</Flexbox>
);
}
@@ -1,28 +0,0 @@
import { SignUp } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { enableClerk } from '@/const/auth';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const locale = await RouteVariants.getLocale(props);
const { t } = await translation('clerk', locale);
return metadataModule.generate({
description: t('signUp.start.subtitle'),
title: t('signUp.start.title'),
url: '/signup',
});
};
const Page = () => {
if (!enableClerk) return notFound();
return <SignUp path="/signup" />;
};
Page.displayName = 'SignUp';
export default Page;
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { Button } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { Form, Input } from 'antd';
import { createStyles } from 'antd-style';
import { ChevronRight, Lock, Mail } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { message } from '@/components/AntdStaticMethods';
import { authEnv } from '@/envs/auth';
import { signUp } from '@/libs/better-auth/auth-client';
const useStyles = createStyles(({ css, token }) => ({
card: css`
padding-block: 2.5rem;
padding-inline: 2rem;
`,
container: css`
width: 360px;
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
`,
footer: css`
padding: 1rem;
border-block-start: 1px solid ${token.colorBorder};
font-size: 14px;
color: ${token.colorTextDescription};
text-align: center;
background: ${token.colorBgElevated};
`,
subtitle: css`
margin-block-start: 0.5rem;
font-size: 14px;
color: ${token.colorTextSecondary};
text-align: center;
`,
title: css`
margin-block-start: 1rem;
font-size: 24px;
font-weight: 600;
color: ${token.colorTextHeading};
text-align: center;
`,
}));
interface SignUpFormValues {
email: string;
password: string;
}
export default function SignUpPage() {
const { styles } = useStyles();
const { t } = useTranslation('auth');
const router = useRouter();
const searchParams = useSearchParams();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// Pre-fill email from query params (from signin page redirect)
useEffect(() => {
const email = searchParams.get('email');
if (email) {
form.setFieldsValue({ email });
}
}, [searchParams, form]);
const handleSignUp = async (values: SignUpFormValues) => {
setLoading(true);
try {
const callbackUrl = searchParams.get('callbackUrl') || '/';
// Generate username from email (use the part before @)
const username = values.email.split('@')[0];
const { error } = await signUp.email({
callbackURL: callbackUrl,
email: values.email,
name: username,
password: values.password,
});
if (error) {
message.error(error.message || t('betterAuth.signup.error'));
return;
}
// Redirect based on email verification requirement
if (authEnv.NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION) {
// Email verification required, redirect to verification notice page
// callbackURL is already passed to signUp.email for verification link
router.push(
`/verify-email?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
);
} else {
// Email verification not required, user is already logged in (autoSignIn: true)
// Redirect to callback URL or home
router.push(callbackUrl);
}
} catch {
message.error(t('betterAuth.signup.error'));
} finally {
setLoading(false);
}
};
return (
<Flexbox align="center" justify="center" style={{ minHeight: '100vh' }}>
<div className={styles.container}>
<div className={styles.card}>
<Flexbox align="center" gap={8} justify="center">
<LobeHub size={48} />
</Flexbox>
<h1 className={styles.title}>{t('betterAuth.signup.title')}</h1>
<p className={styles.subtitle}>{t('betterAuth.signup.subtitle')}</p>
<Form form={form} layout="vertical" onFinish={handleSignUp} style={{ marginTop: '2rem' }}>
<Form.Item
name="email"
rules={[
{ message: t('betterAuth.errors.emailRequired'), required: true },
{ message: t('betterAuth.errors.emailInvalid'), type: 'email' },
]}
>
<Input
placeholder={t('betterAuth.signup.emailPlaceholder')}
prefix={<Mail size={16} />}
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ message: t('betterAuth.errors.passwordRequired'), required: true },
{ message: t('betterAuth.errors.passwordMinLength'), min: 8 },
{ max: 64, message: t('betterAuth.errors.passwordMaxLength') },
{
message: t('betterAuth.errors.passwordFormat'),
validator: (_, value) => {
if (!value) return Promise.resolve();
const hasLetter = /[A-Za-z]/.test(value);
const hasNumber = /\d/.test(value);
if (hasLetter && hasNumber) {
return Promise.resolve();
}
return Promise.reject();
},
},
]}
>
<Input.Password
placeholder={t('betterAuth.signup.passwordPlaceholder')}
prefix={<Lock size={16} />}
size="large"
/>
</Form.Item>
<Form.Item>
<Button
block
htmlType="submit"
icon={<ChevronRight size={16} />}
iconPosition="end"
loading={loading}
size="large"
type="primary"
>
{t('betterAuth.signup.submit')}
</Button>
</Form.Item>
</Form>
</div>
<div className={styles.footer}>
{t('betterAuth.signup.hasAccount')}{' '}
<Link href={`/signin?${searchParams.toString()}`}>
{t('betterAuth.signup.signinLink')}
</Link>
</div>
</div>
</Flexbox>
);
}
@@ -0,0 +1,164 @@
'use client';
import { Button } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { createStyles, useTheme } from 'antd-style';
import { ArrowLeft, Mail, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { message } from '@/components/AntdStaticMethods';
import { sendVerificationEmail } from '@/libs/better-auth/auth-client';
const useStyles = createStyles(({ css, token }) => ({
backLink: css`
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 14px;
color: ${token.colorTextSecondary};
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: ${token.colorText};
}
`,
container: css`
max-width: 480px;
padding: 2rem;
text-align: center;
`,
description: css`
font-size: 16px;
line-height: 1.6;
color: ${token.colorText};
`,
hint: css`
margin-block-start: 0.5rem;
font-size: 14px;
color: ${token.colorTextTertiary};
`,
iconWrapper: css`
display: inline-flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(
135deg,
${token.colorPrimaryBg} 0%,
${token.colorPrimaryBgHover} 100%
);
`,
mailLink: css`
font-weight: 500;
color: ${token.colorPrimary};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`,
resendButton: css`
margin-block-start: 0.5rem;
`,
textGroup: css`
display: flex;
flex-direction: column;
gap: 0.5rem;
`,
title: css`
margin-block: 0;
font-size: 28px;
font-weight: 600;
color: ${token.colorTextHeading};
`,
}));
export default function VerifyEmailPage() {
const { styles } = useStyles();
const theme = useTheme();
const { t } = useTranslation('auth');
const searchParams = useSearchParams();
const email = searchParams.get('email');
const [resending, setResending] = useState(false);
const handleResendEmail = async () => {
if (!email) {
message.error(t('betterAuth.verifyEmail.resend.noEmail'));
return;
}
setResending(true);
try {
const callbackUrl = searchParams.get('callbackUrl') || '/';
const result = await sendVerificationEmail({
callbackURL: callbackUrl,
email,
});
if (result.error) {
message.error(result.error.message || t('betterAuth.verifyEmail.resend.error'));
return;
}
message.success(t('betterAuth.verifyEmail.resend.success'));
} catch (error) {
console.error('Error resending verification email:', error);
message.error(t('betterAuth.verifyEmail.resend.error'));
} finally {
setResending(false);
}
};
return (
<Center style={{ minHeight: '100vh' }}>
<Flexbox align="center" className={styles.container} gap={24}>
<LobeHub size={56} />
<h1 className={styles.title}>{t('betterAuth.verifyEmail.title')}</h1>
<div className={styles.iconWrapper}>
<Mail color={theme.colorPrimary} size={40} strokeWidth={1.5} />
</div>
<div className={styles.textGroup}>
<p className={styles.description}>
{t('betterAuth.verifyEmail.descriptionPrefix')}{' '}
<a className={styles.mailLink} href={`mailto:${email}`}>
{email}
</a>{' '}
{t('betterAuth.verifyEmail.descriptionSuffix')}
</p>
<p className={styles.hint}>{t('betterAuth.verifyEmail.checkSpam')}</p>
</div>
<Button
className={styles.resendButton}
icon={<RefreshCw size={16} />}
loading={resending}
onClick={handleResendEmail}
size="middle"
type="default"
>
{t('betterAuth.verifyEmail.resend.button')}
</Button>
<Link className={styles.backLink} href="/signin">
<ArrowLeft size={16} />
{t('betterAuth.verifyEmail.backToSignIn')}
</Link>
</Flexbox>
</Center>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { serverDB } from '@lobechat/database';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { authEnv } from '@/envs/auth';
import {
getResetPasswordEmailTemplate,
getVerificationEmailTemplate,
} from '@/libs/better-auth/email-templates';
import { emailService } from '@/server/services/email';
// Email verification link expiration time (in seconds)
// Default is 1 hour (3600 seconds) as per Better Auth documentation
const VERIFICATION_LINK_EXPIRES_IN = 3600;
export const auth = betterAuth({
database: drizzleAdapter(serverDB, {
provider: 'pg',
}),
emailAndPassword: {
autoSignIn: true,
enabled: true,
maxPasswordLength: 64,
minPasswordLength: 8,
requireEmailVerification: authEnv.NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION,
sendResetPassword: async ({ user, url }) => {
const template = getResetPasswordEmailTemplate({ url });
await emailService.sendMail({
to: user.email,
...template,
});
},
},
emailVerification: {
autoSignInAfterVerification: true,
expiresIn: VERIFICATION_LINK_EXPIRES_IN,
sendVerificationEmail: async ({ user, url }) => {
const template = getVerificationEmailTemplate({
expiresInSeconds: VERIFICATION_LINK_EXPIRES_IN,
url,
userName: user.name,
});
await emailService.sendMail({
to: user.email,
...template,
});
},
},
user: {
fields: {
image: 'avatar',
name: 'username',
},
modelName: 'users',
},
});
+19
View File
@@ -11,6 +11,11 @@ declare global {
CLERK_SECRET_KEY?: string;
CLERK_WEBHOOK_SECRET?: string;
// ===== Better Auth ===== //
BETTER_AUTH_SECRET?: string;
NEXT_PUBLIC_BETTER_AUTH_URL?: string;
NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION?: string;
// ===== Next Auth ===== //
NEXT_AUTH_SECRET?: string;
@@ -46,6 +51,10 @@ export const getAuthConfig = () => {
*/
NEXT_PUBLIC_ENABLE_CLERK_AUTH: z.boolean().optional(),
NEXT_PUBLIC_ENABLE_BETTER_AUTH: z.boolean().optional(),
NEXT_PUBLIC_BETTER_AUTH_URL: z.string().optional(),
NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION: z.boolean().optional().default(false),
NEXT_PUBLIC_ENABLE_NEXT_AUTH: z.boolean().optional(),
},
server: {
@@ -53,6 +62,9 @@ export const getAuthConfig = () => {
CLERK_SECRET_KEY: z.string().optional(),
CLERK_WEBHOOK_SECRET: z.string().optional(),
// Better Auth
BETTER_AUTH_SECRET: z.string().optional(),
// NEXT-AUTH
NEXT_AUTH_SECRET: z.string().optional(),
NEXT_AUTH_SSO_PROVIDERS: z.string().optional().default('auth0'),
@@ -77,6 +89,13 @@ export const getAuthConfig = () => {
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
// Better Auth
NEXT_PUBLIC_ENABLE_BETTER_AUTH: process.env.NEXT_PUBLIC_ENABLE_BETTER_AUTH === '1',
NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION:
process.env.NEXT_PUBLIC_BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION === '1',
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
// Next Auth
NEXT_PUBLIC_ENABLE_NEXT_AUTH: process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1',
NEXT_AUTH_SSO_PROVIDERS: process.env.NEXT_AUTH_SSO_PROVIDERS,
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {
SMTP_HOST?: string;
SMTP_PASS?: string;
SMTP_PORT?: string;
SMTP_SECURE?: string;
SMTP_USER?: string;
}
}
}
export const getEmailConfig = () => {
return createEnv({
server: {
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_SECURE: z.boolean().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
},
runtimeEnv: {
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined,
SMTP_SECURE: process.env.SMTP_SECURE === 'true',
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
},
});
};
export const emailEnv = getEmailConfig();
+4 -3
View File
@@ -5,7 +5,7 @@ import { Flexbox } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
import Menu from '@/components/Menu';
import { enableAuth, enableNextAuth } from '@/const/auth';
import { enableAuth, enableBetterAuth, enableNextAuth } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
@@ -31,8 +31,9 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
const handleSignOut = () => {
signOut();
closePopover();
// NextAuth doesn't need to redirect to login page
if (enableNextAuth) return;
// NextAuth and Better Auth handle redirect in their own signOut methods
if (enableNextAuth || enableBetterAuth) return;
// Clerk uses /login page
router.push('/login');
};
@@ -0,0 +1,46 @@
'use client';
import { memo, useEffect } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { useSession } from '@/libs/better-auth/auth-client';
import { useUserStore } from '@/store/user';
import { LobeUser } from '@/types/user';
/**
* Sync Better-Auth session state to Zustand store
*/
const UserUpdater = memo(() => {
const { data: session, isPending, error } = useSession();
const isLoaded = !isPending;
const isSignedIn = !!session?.user && !error;
const betterAuthUser = session?.user;
const useStoreUpdater = createStoreUpdater(useUserStore);
useStoreUpdater('isLoaded', isLoaded);
useStoreUpdater('isSignedIn', isSignedIn);
// Sync user data from Better-Auth session to Zustand store
useEffect(() => {
if (betterAuthUser) {
const userAvatar = useUserStore.getState().user?.avatar;
const lobeUser = {
// Preserve avatar from settings, don't override with auth provider value
avatar: userAvatar || '',
email: betterAuthUser.email,
fullName: betterAuthUser.name,
id: betterAuthUser.id,
} as LobeUser;
// Update user data in store
useUserStore.setState({ user: lobeUser });
}
}, [betterAuthUser]);
return null;
});
export default UserUpdater;
@@ -0,0 +1,14 @@
import { PropsWithChildren } from 'react';
import UserUpdater from './UserUpdater';
const BetterAuth = ({ children }: PropsWithChildren) => {
return (
<>
{children}
<UserUpdater />
</>
);
};
export default BetterAuth;
+3
View File
@@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react';
import { isDesktop } from '@/const/version';
import { authEnv } from '@/envs/auth';
import BetterAuth from './BetterAuth';
import Clerk from './Clerk';
import { MarketAuthProvider } from './MarketAuth';
import NextAuth from './NextAuth';
@@ -13,6 +14,8 @@ const AuthProvider = ({ children }: PropsWithChildren) => {
let InnerAuthProvider;
if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH) {
InnerAuthProvider = ({ children }: PropsWithChildren) => <Clerk>{children}</Clerk>;
} else if (authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH) {
InnerAuthProvider = ({ children }: PropsWithChildren) => <BetterAuth>{children}</BetterAuth>;
} else if (authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH) {
InnerAuthProvider = ({ children }: PropsWithChildren) => <NextAuth>{children}</NextAuth>;
} else {
+13
View File
@@ -0,0 +1,13 @@
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import type { auth } from '@/auth';
import { getAuthConfig } from '@/envs/auth';
const { NEXT_PUBLIC_BETTER_AUTH_URL } = getAuthConfig();
export const { sendVerificationEmail, signIn, signOut, signUp, useSession } = createAuthClient({
/** The base URL of the server (optional if you're using the same domain) */
baseURL: NEXT_PUBLIC_BETTER_AUTH_URL,
plugins: [inferAdditionalFields<typeof auth>()],
});
@@ -0,0 +1,2 @@
export { getResetPasswordEmailTemplate } from './reset-password';
export { getVerificationEmailTemplate } from './verification';
@@ -0,0 +1,58 @@
/**
* Password reset email template
* Sent to users when they request a password reset
*/
export const getResetPasswordEmailTemplate = (params: { url: string }) => {
const { url } = params;
return {
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="text-align: center; margin-bottom: 40px;">
<h1 style="color: #1a1a1a; font-size: 32px; margin: 0;">🤯 LobeChat</h1>
</div>
<div style="background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 32px;">
<h2 style="color: #1a1a1a; font-size: 24px; margin: 0 0 16px 0;">Reset Your Password</h2>
<p style="color: #6b7280; font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
We received a request to reset your password. Click the button below to create a new password:
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${url}"
style="display: inline-block; background: #1a1a1a; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 500; font-size: 16px;">
Reset Password
</a>
</div>
<p style="color: #9ca3af; font-size: 14px; line-height: 1.5; margin: 24px 0 0 0;">
If you didn't request this password reset, you can safely ignore this email.
</p>
<p style="color: #9ca3af; font-size: 14px; line-height: 1.5; margin: 16px 0 0 0;">
Or copy and paste this URL into your browser:<br>
<a href="${url}" style="color: #3b82f6; word-break: break-all;">${url}</a>
</p>
</div>
<div style="text-align: center; margin-top: 32px;">
<p style="color: #9ca3af; font-size: 14px; margin: 0;">
© ${new Date().getFullYear()} LobeChat. All rights reserved.
</p>
</div>
</div>
</body>
</html>
`,
subject: 'Reset Your Password - LobeChat',
text: `Reset your password by clicking this link: ${url}`,
};
};
@@ -0,0 +1,126 @@
/**
* Email verification template
* Sent to users when they sign up to verify their email address
*/
export const getVerificationEmailTemplate = (params: {
expiresInSeconds: number;
url: string;
userName?: string | null;
}) => {
const { url, userName, expiresInSeconds } = params;
// Format expiration time in a human-readable way
const expiresInHours = expiresInSeconds / 3600;
const expirationText =
expiresInHours >= 1
? `${expiresInHours} hour${expiresInHours > 1 ? 's' : ''}`
: `${expiresInSeconds / 60} minutes`;
return {
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<!-- Logo Section -->
<div style="text-align: center; margin-bottom: 32px;">
<h1 style="color: #18181b; font-size: 28px; font-weight: 700; margin: 0; letter-spacing: -1px;">
🤯 LobeChat
</h1>
</div>
<!-- Main Card -->
<div style="background: #ffffff; border-radius: 20px; padding: 48px 40px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.02);">
<!-- Title -->
<h2 style="color: #0a0a0a; font-size: 28px; font-weight: 700; margin: 0 0 8px 0; text-align: center; letter-spacing: -0.5px;">
Verify Your Email
</h2>
<!-- Subtitle -->
<p style="color: #737373; font-size: 15px; margin: 0 0 32px 0; text-align: center;">
Welcome to LobeChat, ${userName || 'there'}! 👋
</p>
<!-- Main Message -->
<div style="background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-radius: 12px; padding: 24px; margin: 0 0 32px 0; border: 1px solid #e2e8f0;">
<p style="color: #475569; font-size: 15px; line-height: 1.6; margin: 0; text-align: center;">
To get started with your AI-powered conversations, please verify your email address.
</p>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 0 0 32px 0;">
<a href="${url}"
style="display: inline-block;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #ffffff;
text-decoration: none;
padding: 18px 56px;
border-radius: 12px;
font-weight: 600;
font-size: 16px;
letter-spacing: 0.3px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4), 0 8px 24px rgba(59, 130, 246, 0.2);">
Verify Email Address →
</a>
</div>
<!-- Security Notice -->
<div style="background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 100%); border-left: 4px solid #0ea5e9; padding: 20px; border-radius: 8px; margin: 0 0 24px 0;">
<div style="display: flex; align-items: start;">
<p style="color: #0369a1; font-size: 13px; line-height: 1.6; margin: 0;">
<span style="display: block; font-weight: 700; font-size: 14px; margin-bottom: 6px;">🔒 Security First</span>
This verification link will expire in <strong>${expirationText}</strong> to protect your account.
</p>
</div>
</div>
<!-- Alternative Link Section -->
<details style="margin: 24px 0 0 0;">
<summary style="color: #71717a; font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; padding: 12px; background: #fafafa; border-radius: 8px; list-style: none; text-align: center;">
⚙️ Having trouble? Click here for alternative options
</summary>
<div style="margin-top: 16px; padding: 16px; background: #fafafa; border: 2px dashed #e4e4e7; border-radius: 12px;">
<p style="color: #71717a; font-size: 12px; font-weight: 600; margin: 0 0 10px 0; text-transform: uppercase; letter-spacing: 0.5px;">
Copy & Paste This Link:
</p>
<div style="background: #ffffff; border: 1px solid #e4e4e7; border-radius: 8px; padding: 12px; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);">
<p style="margin: 0; font-family: 'SF Mono', 'Monaco', 'Courier New', monospace; font-size: 12px; color: #18181b; word-break: break-all; user-select: all; line-height: 1.5;">
${url}
</p>
</div>
</div>
</details>
<!-- Divider -->
<div style="height: 1px; background: linear-gradient(to right, transparent 0%, #e5e7eb 50%, transparent 100%); margin: 40px 0 24px 0;"></div>
<!-- Footer Note -->
<p style="color: #a1a1aa; font-size: 13px; line-height: 1.5; margin: 0; text-align: center;">
Didn't create an account? You can safely ignore this email.
</p>
</div>
<!-- Brand Footer -->
<div style="text-align: center; margin-top: 40px;">
<p style="color: #a1a1aa; font-size: 12px; margin: 0 0 4px 0; font-weight: 500;">
© ${new Date().getFullYear()} LobeChat · All rights reserved
</p>
<p style="color: #d4d4d8; font-size: 11px; margin: 0; letter-spacing: 0.3px;">
Your Modern AI Chat Interface
</p>
</div>
</div>
</body>
</html>
`,
subject: 'Verify Your Email - LobeChat',
text: `Please verify your email by clicking this link: ${url}\n\nThis link will expire in ${expirationText}.`,
};
};
+27
View File
@@ -7,6 +7,7 @@ import { NextRequest } from 'next/server';
import {
LOBE_CHAT_AUTH_HEADER,
LOBE_CHAT_OIDC_AUTH_HEADER,
enableBetterAuth,
enableClerk,
enableNextAuth,
} from '@/const/auth';
@@ -163,6 +164,32 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
});
}
if (enableBetterAuth) {
log('Attempting Better Auth authentication');
try {
const { auth: betterAuth } = await import('@/auth');
const session = await betterAuth.api.getSession({
headers: request.headers,
});
if (session && session?.user?.id) {
userId = session.user.id;
log('Better Auth authentication successful, userId: %s', userId);
} else {
log('Better Auth authentication failed, no valid session');
}
return createContextInner({
...commonContext,
userId,
});
} catch (e) {
log('Better Auth authentication error: %O', e);
console.error('better auth err', e);
}
}
if (enableNextAuth) {
log('Attempting NextAuth authentication');
try {
+4 -2
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { enableClerk } from '@/const/auth';
import { enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
import { DESKTOP_USER_ID } from '@/const/desktop';
import { isDesktop } from '@/const/version';
@@ -19,7 +19,9 @@ export const userAuth = trpc.middleware(async (opts) => {
if (!ctx.userId) {
if (enableClerk) {
console.log('clerk auth:', ctx.clerkAuth);
} else {
} else if (enableBetterAuth) {
console.log('better auth: no session found in context');
} else if (enableNextAuth) {
console.log('next auth:', ctx.nextAuth);
}
throw new TRPCError({ code: 'UNAUTHORIZED' });
+60
View File
@@ -52,6 +52,66 @@ export default {
required: '内容不得为空',
},
},
betterAuth: {
errors: {
emailInvalid: '请输入有效的邮箱地址',
emailNotRegistered: '该邮箱尚未注册',
emailNotVerified: '邮箱尚未验证,请先验证邮箱',
emailRequired: '请输入邮箱地址',
firstNameRequired: '请输入名字',
lastNameRequired: '请输入姓氏',
loginFailed: '登录失败,请检查邮箱和密码',
passwordFormat: '密码必须同时包含字母和数字',
passwordMaxLength: '密码最多不超过 64 个字符',
passwordMinLength: '密码至少需要 8 个字符',
passwordRequired: '请输入密码',
usernameRequired: '请输入用户名',
},
signin: {
backToEmail: '返回修改邮箱',
emailPlaceholder: '请输入邮箱地址',
emailStep: {
subtitle: '请输入您的邮箱地址以继续',
title: '登录',
},
error: '登录失败,请检查邮箱和密码',
nextStep: '下一步',
noAccount: '还没有账号?',
passwordPlaceholder: '请输入密码',
passwordStep: {
subtitle: '请输入密码以继续',
},
signupLink: '立即注册',
submit: '登录',
},
signup: {
emailPlaceholder: '请输入邮箱地址',
error: '注册失败,请重试',
firstNamePlaceholder: '名字',
hasAccount: '已有账号?',
lastNamePlaceholder: '姓氏',
passwordPlaceholder: '请输入密码(8-64位,需包含字母和数字)',
signinLink: '立即登录',
submit: '注册',
subtitle: '加入 LobeChat 社区',
success: '注册成功!请检查您的邮箱验证邮件',
title: '创建账号',
usernamePlaceholder: '请输入用户名',
},
verifyEmail: {
backToSignIn: '返回登录',
checkSpam: '如果没有收到邮件,请检查垃圾邮件文件夹',
descriptionPrefix: '我们已向',
descriptionSuffix: '发送了验证邮件',
resend: {
button: '重新发送验证邮件',
error: '发送失败,请稍后重试',
noEmail: '邮箱地址缺失',
success: '验证邮件已重新发送,请检查您的邮箱',
},
title: '验证您的邮箱',
},
},
date: {
prevMonth: '上个月',
recent30Days: '最近30天',
+56 -3
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { UAParser } from 'ua-parser-js';
import urlJoin from 'url-join';
import { auth } from '@/auth';
import { OAUTH_AUTHORIZED } from '@/const/auth';
import { LOBE_LOCALE_COOKIE } from '@/const/locale';
import { LOBE_THEME_APPEARANCE } from '@/const/theme';
@@ -21,6 +22,7 @@ import { RouteVariants } from './utils/server/routeVariants';
const logDefault = debug('middleware:default');
const logNextAuth = debug('middleware:next-auth');
const logClerk = debug('middleware:clerk');
const logBetterAuth = debug('middleware:better-auth');
// OIDC session pre-sync constant
const OIDC_SESSION_HEADER = 'x-oidc-session-sync';
@@ -47,11 +49,15 @@ export const config = {
'/login(.*)',
'/signup(.*)',
'/signin(.*)',
'/verify-email(.*)',
'/next-auth/(.*)',
'/oauth(.*)',
'/oidc(.*)',
// ↓ cloud ↓
],
// Enable Node.js runtime for better-auth session validation (Next.js 15.2.0+)
runtime: 'nodejs',
};
const backendApiEndpoints = ['/api', '/trpc', '/webapi', '/oidc'];
@@ -182,6 +188,9 @@ const isPublicRoute = createRouteMatcher([
// clerk
'/login',
'/signup',
// better auth
'/signin',
'/verify-email',
// oauth
// Make only the consent view public (GET page), not other oauth paths
'/oauth/consent/(.*)',
@@ -292,8 +301,50 @@ const clerkAuthMiddleware = clerkMiddleware(
},
);
const betterAuthMiddleware = async (req: NextRequest) => {
logBetterAuth('BetterAuth middleware processing request: %s %s', req.method, req.url);
const response = defaultMiddleware(req);
// when enable auth protection, only public route is not protected, others are all protected
const isProtected = appEnv.ENABLE_AUTH_PROTECTION ? !isPublicRoute(req) : isProtectedRoute(req);
logBetterAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public');
// Get full session with user data (Next.js 15.2.0+ feature)
const session = await auth.api.getSession({
headers: req.headers,
});
const isLoggedIn = !!session?.user;
logBetterAuth('BetterAuth session status: %O', {
isLoggedIn,
userId: session?.user?.id,
});
if (!isLoggedIn) {
// If request a protected route, redirect to sign-in page
if (isProtected) {
logBetterAuth('Request a protected route, redirecting to sign-in page');
const signInUrl = new URL('/signin', req.nextUrl.origin);
signInUrl.searchParams.set('callbackUrl', req.nextUrl.href);
const hl = req.nextUrl.searchParams.get('hl');
if (hl) {
signInUrl.searchParams.set('hl', hl);
logBetterAuth('Preserving locale to sign-in: hl=%s', hl);
}
return Response.redirect(signInUrl);
}
logBetterAuth('Request a free route but not login, allow visit without auth header');
}
return response;
};
logDefault('Middleware configuration: %O', {
enableAuthProtection: appEnv.ENABLE_AUTH_PROTECTION,
enableBetterAuth: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH,
enableClerk: authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH,
enableNextAuth: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH,
enableOIDC: oidcEnv.ENABLE_OIDC,
@@ -301,6 +352,8 @@ logDefault('Middleware configuration: %O', {
export default authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH
? clerkAuthMiddleware
: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
? nextAuthMiddleware
: defaultMiddleware;
: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH
? betterAuthMiddleware
: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
? nextAuthMiddleware
: defaultMiddleware;
+233
View File
@@ -0,0 +1,233 @@
# Email Service
A flexible email service implementation supporting multiple email providers.
## Architecture
Based on the search service pattern, this service provides a unified interface for sending emails across different providers.
```plaintext
EmailService
└── EmailServiceImpl (interface)
└── NodemailerImpl (SMTP provider)
```
## Usage
### Basic Example
```typescript
import { emailService } from '@/server/services/email';
// Send a simple text email
await emailService.sendMail({
from: 'noreply@example.com',
to: 'user@example.com',
subject: 'Welcome to LobeChat',
text: 'Thanks for signing up!',
html: '<p>Thanks for signing up!</p>',
});
```
### With Multiple Recipients
```typescript
await emailService.sendMail({
from: 'team@example.com',
to: ['user1@example.com', 'user2@example.com'],
subject: 'Team Update',
text: 'Check out our latest updates',
});
```
### With Attachments
```typescript
await emailService.sendMail({
from: 'support@example.com',
to: 'user@example.com',
subject: 'Your Invoice',
text: 'Please find your invoice attached.',
attachments: [
{
filename: 'invoice.pdf',
path: '/path/to/invoice.pdf',
},
],
});
```
### With Reply-To Address
```typescript
await emailService.sendMail({
from: 'noreply@example.com',
replyTo: 'support@example.com',
to: 'user@example.com',
subject: 'Contact Us',
text: 'Reply to this email for support.',
});
```
## Configuration
### Environment Variables
Configure SMTP settings using environment variables:
```bash
# SMTP Server Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false # true for port 465, false for other ports
SMTP_USER=your-username
SMTP_PASS=your-password
```
### Using Well-Known Services
You can also use well-known email services (Gmail, SendGrid, etc.):
```typescript
import { EmailImplType, EmailService } from '@/server/services/email';
import { NodemailerImpl } from '@/server/services/email/impls/nodemailer';
const emailService = new EmailService(EmailImplType.Nodemailer);
// Configure in constructor with service name
```
### Testing with Ethereal
For development and testing, use [Ethereal Email](https://ethereal.email/):
```typescript
// The preview URL will be logged automatically in development
const result = await emailService.sendMail({...});
console.log('Preview URL:', result.previewUrl);
```
## Verify Connection
Before sending emails, verify your SMTP configuration:
```typescript
import { emailService } from '@/server/services/email';
try {
await emailService.verify();
console.log('SMTP connection verified ✓');
} catch (error) {
console.error('SMTP verification failed:', error);
}
```
## Integration with Better-Auth
Example integration for email verification:
```typescript
import { betterAuth } from 'better-auth';
import { emailService } from '@/server/services/email';
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
sendResetPasswordEmail: async ({ user, url }) => {
await emailService.sendMail({
from: 'noreply@lobechat.com',
to: user.email,
subject: 'Reset Your Password',
text: `Click here to reset your password: ${url}`,
html: `
<h1>Reset Your Password</h1>
<p>Click the link below to reset your password:</p>
<a href="${url}">Reset Password</a>
`,
});
},
},
emailVerification: {
enabled: true,
sendVerificationEmail: async ({ user, url }) => {
await emailService.sendMail({
from: 'noreply@lobechat.com',
to: user.email,
subject: 'Verify Your Email',
text: `Click here to verify your email: ${url}`,
html: `
<h1>Verify Your Email</h1>
<p>Click the link below to verify your email address:</p>
<a href="${url}">Verify Email</a>
`,
});
},
},
});
```
## Adding New Providers
To add a new email provider (e.g., Resend, SendGrid):
1. Create provider implementation in `impls/[provider-name]/index.ts`:
```typescript
import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
export class ResendImpl implements EmailServiceImpl {
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
// Implement using Resend API
}
}
```
2. Add to the enum in `impls/index.ts`:
```typescript
export enum EmailImplType {
Nodemailer = 'nodemailer',
Resend = 'resend', // Add new provider
}
```
3. Update factory function in `impls/index.ts`:
```typescript
export const createEmailServiceImpl = (type: EmailImplType) => {
switch (type) {
case EmailImplType.Nodemailer:
return new NodemailerImpl();
case EmailImplType.Resend:
return new ResendImpl();
default:
return new NodemailerImpl();
}
};
```
## Error Handling
The service throws `TRPCError` for various failure scenarios:
```typescript
try {
await emailService.sendMail({...});
} catch (error) {
if (error.code === 'SERVICE_UNAVAILABLE') {
// Handle SMTP connection issues
} else if (error.code === 'PRECONDITION_FAILED') {
// Handle configuration errors
}
}
```
## Debugging
Enable debug logging:
```bash
DEBUG=lobe-email:* node your-app.js
```
This will log detailed information about email sending operations.
+32
View File
@@ -0,0 +1,32 @@
import { NodemailerImpl } from './nodemailer';
import { EmailServiceImpl } from './type';
/**
* Available email service implementations
*/
export enum EmailImplType {
Nodemailer = 'nodemailer',
// Future providers can be added here:
// Resend = 'resend',
// SendGrid = 'sendgrid',
}
/**
* Create an email service implementation instance
*/
export const createEmailServiceImpl = (
type: EmailImplType = EmailImplType.Nodemailer,
): EmailServiceImpl => {
switch (type) {
case EmailImplType.Nodemailer: {
return new NodemailerImpl();
}
default: {
return new NodemailerImpl();
}
}
};
export type { EmailServiceImpl } from './type';
export type { EmailPayload, EmailResponse } from './type';
@@ -0,0 +1,122 @@
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import { emailEnv } from '@/envs/email';
import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
import { NodemailerConfig } from './type';
const log = debug('lobe-email:Nodemailer');
/**
* Nodemailer implementation of the email service
*/
export class NodemailerImpl implements EmailServiceImpl {
private transporter: Transporter;
constructor(config?: NodemailerConfig) {
log('Initializing Nodemailer with config: %o', config);
// Use environment variables if config is not provided
const transportConfig: NodemailerConfig = config ?? {
auth: {
pass: emailEnv.SMTP_PASS ?? '',
user: emailEnv.SMTP_USER ?? '',
},
host: emailEnv.SMTP_HOST ?? 'localhost',
port: emailEnv.SMTP_PORT ?? 587,
secure: emailEnv.SMTP_SECURE ?? false,
};
// Validate configuration
if (!transportConfig.service && !transportConfig.host) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Nodemailer requires either service name or SMTP host to be configured',
});
}
if (
!transportConfig.service &&
transportConfig.auth &&
(!transportConfig.auth.user || !transportConfig.auth.pass)
) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Nodemailer requires SMTP authentication credentials',
});
}
try {
this.transporter = nodemailer.createTransport(transportConfig);
log('Nodemailer transporter created successfully');
} catch (error) {
log.extend('error')('Failed to create Nodemailer transporter: %o', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to initialize Nodemailer transport',
});
}
}
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
// Use SMTP_USER as default sender if not provided
const from = payload.from ?? emailEnv.SMTP_USER!;
log('Sending email with payload: %o', {
from,
subject: payload.subject,
to: payload.to,
});
try {
const info = await this.transporter.sendMail({
attachments: payload.attachments,
from,
html: payload.html,
replyTo: payload.replyTo,
subject: payload.subject,
text: payload.text,
to: payload.to,
});
log('Email sent successfully with message ID: %s', info.messageId);
const previewUrl = nodemailer.getTestMessageUrl(info);
return {
messageId: info.messageId,
previewUrl: previewUrl || undefined,
};
} catch (error) {
log.extend('error')('Failed to send email: %o', error);
throw new TRPCError({
cause: error,
code: 'SERVICE_UNAVAILABLE',
message: `Failed to send email: ${(error as Error).message}`,
});
}
}
/**
* Verify the SMTP connection configuration
*/
async verify(): Promise<boolean> {
try {
log('Verifying SMTP connection...');
await this.transporter.verify();
log('SMTP connection verified successfully');
return true;
} catch (error) {
log.extend('error')('SMTP verification failed: %o', error);
throw new TRPCError({
cause: error,
code: 'SERVICE_UNAVAILABLE',
message: 'Failed to verify SMTP connection',
});
}
}
}
@@ -0,0 +1,31 @@
/**
* Nodemailer SMTP transport configuration
*/
export interface NodemailerConfig {
/**
* Authentication credentials
*/
auth?: {
pass: string;
user: string;
};
/**
* SMTP server hostname
*/
host?: string;
/**
* SMTP server port
* @default 587
*/
port?: number;
/**
* Use TLS connection
* @default false
*/
secure?: boolean;
/**
* Well-known service name (e.g., 'Gmail', 'SendGrid')
* When set, overrides host, port, and secure
*/
service?: string;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Email message payload
*/
export interface EmailPayload {
/**
* Email attachments
*/
attachments?: Array<{
content?: Buffer | string;
filename?: string;
path?: string;
}>;
/**
* Sender address (defaults to SMTP_USER if not provided)
*/
from?: string;
/**
* HTML body of the email
*/
html?: string;
/**
* Reply-To address
*/
replyTo?: string;
/**
* Subject line
*/
subject: string;
/**
* Plain text body of the email
*/
text?: string;
/**
* Recipient address(es)
*/
to: string | string[];
}
/**
* Email send response
*/
export interface EmailResponse {
/**
* Message ID assigned by the email service
*/
messageId: string;
/**
* Preview URL for test emails (e.g., Ethereal)
*/
previewUrl?: string;
}
/**
* Email service implementation interface
*/
export interface EmailServiceImpl {
/**
* Send an email
*/
sendMail(payload: EmailPayload): Promise<EmailResponse>;
}
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EmailImplType, createEmailServiceImpl } from './impls';
import { EmailService } from './index';
// Mock dependencies
vi.mock('./impls');
describe('EmailService', () => {
let emailService: EmailService;
let mockEmailImpl: ReturnType<typeof createMockEmailImpl>;
function createMockEmailImpl() {
return {
sendMail: vi.fn(),
verify: vi.fn(),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockEmailImpl = createMockEmailImpl();
vi.mocked(createEmailServiceImpl).mockReturnValue(mockEmailImpl as any);
emailService = new EmailService();
});
describe('constructor', () => {
it('should create instance with default email implementation', () => {
expect(createEmailServiceImpl).toHaveBeenCalledWith(undefined);
});
it('should create instance with specified implementation type', () => {
emailService = new EmailService(EmailImplType.Nodemailer);
expect(createEmailServiceImpl).toHaveBeenCalledWith(EmailImplType.Nodemailer);
});
});
describe('sendMail', () => {
it('should call emailImpl.sendMail with correct payload', async () => {
const mockResponse = {
messageId: 'test-message-id',
previewUrl: 'https://ethereal.email/message/xxx',
};
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
const payload = {
from: 'sender@example.com',
html: '<p>Hello world</p>',
subject: 'Test Email',
text: 'Hello world',
to: 'recipient@example.com',
};
const result = await emailService.sendMail(payload);
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
expect(result).toBe(mockResponse);
});
it('should support multiple recipients', async () => {
const mockResponse = {
messageId: 'test-message-id',
};
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
const payload = {
from: 'sender@example.com',
subject: 'Test Email',
text: 'Hello world',
to: ['recipient1@example.com', 'recipient2@example.com'],
};
await emailService.sendMail(payload);
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
});
it('should support attachments', async () => {
const mockResponse = {
messageId: 'test-message-id',
};
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
const payload = {
attachments: [
{
content: Buffer.from('test content'),
filename: 'test.txt',
},
],
from: 'sender@example.com',
subject: 'Test Email',
text: 'Hello world',
to: 'recipient@example.com',
};
await emailService.sendMail(payload);
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
});
it('should support reply-to address', async () => {
const mockResponse = {
messageId: 'test-message-id',
};
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
const payload = {
from: 'noreply@example.com',
replyTo: 'support@example.com',
subject: 'Test Email',
text: 'Hello world',
to: 'recipient@example.com',
};
await emailService.sendMail(payload);
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
});
});
describe('verify', () => {
it('should call emailImpl.verify if available', async () => {
mockEmailImpl.verify.mockResolvedValue(true);
const result = await emailService.verify();
expect(mockEmailImpl.verify).toHaveBeenCalled();
expect(result).toBe(true);
});
it('should return true if verify method is not available', async () => {
const mockImplWithoutVerify = {
sendMail: vi.fn(),
};
vi.mocked(createEmailServiceImpl).mockReturnValue(mockImplWithoutVerify as any);
emailService = new EmailService();
const result = await emailService.verify();
expect(result).toBe(true);
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import { emailEnv } from '@/envs/email';
import { EmailImplType, EmailPayload, EmailResponse, createEmailServiceImpl } from './impls';
import type { EmailServiceImpl } from './impls';
/**
* Email service class
* Provides email sending functionality with multiple provider support
*/
export class EmailService {
private emailImpl: EmailServiceImpl;
constructor(implType?: EmailImplType) {
// Validate SMTP_USER is configured
if (!emailEnv.SMTP_USER) {
throw new Error(
'SMTP_USER environment variable is required to use email service. Please configure SMTP settings in your .env file.',
);
}
this.emailImpl = createEmailServiceImpl(implType);
}
/**
* Send an email
*/
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
return this.emailImpl.sendMail(payload);
}
/**
* Verify the email service configuration
* Note: Only available for Nodemailer implementation
*/
async verify(): Promise<boolean> {
// Check if the implementation has a verify method
if ('verify' in this.emailImpl && typeof this.emailImpl.verify === 'function') {
return this.emailImpl.verify();
}
// For implementations without verify, assume it's valid
return true;
}
}
// Export a default instance for convenience
export const emailService = new EmailService();
// Export types
export type { EmailPayload, EmailResponse } from './impls';
export { EmailImplType } from './impls';
+24 -2
View File
@@ -1,6 +1,6 @@
import { StateCreator } from 'zustand/vanilla';
import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
import { enableAuth, enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
import type { UserStore } from '../../store';
@@ -21,7 +21,7 @@ export const createAuthSlice: StateCreator<
[['zustand/devtools', never]],
[],
UserAuthAction
> = (set, get) => ({
> = (_set, get) => ({
enableAuth: () => {
return enableAuth;
},
@@ -32,6 +32,21 @@ export const createAuthSlice: StateCreator<
return;
}
if (enableBetterAuth) {
const { signOut } = await import('@/libs/better-auth/auth-client');
await signOut({
fetchOptions: {
onSuccess: () => {
// Use window.location.href to trigger a full page reload
// This ensures all client-side state (React, Zustand, cache) is cleared
window.location.href = '/signin';
},
},
});
return;
}
if (enableNextAuth) {
const { signOut } = await import('next-auth/react');
signOut();
@@ -49,6 +64,13 @@ export const createAuthSlice: StateCreator<
return;
}
if (enableBetterAuth) {
const currentUrl = location.toString();
window.location.href = `/signin?callbackUrl=${encodeURIComponent(currentUrl)}`;
return;
}
if (enableNextAuth) {
const { signIn } = await import('next-auth/react');
// Check if only one provider is available
+2 -1
View File
@@ -2,7 +2,7 @@ import { BRANDING_NAME, isDesktop } from '@lobechat/const';
import { LobeUser } from '@lobechat/types';
import { t } from 'i18next';
import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
import { enableAuth, enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
import type { UserStore } from '@/store/user';
const DEFAULT_USERNAME = BRANDING_NAME;
@@ -59,6 +59,7 @@ export const authSelectors = {
isLoaded: (s: UserStore) => s.isLoaded,
isLogin,
isLoginWithAuth: (s: UserStore) => s.isSignedIn,
isLoginWithBetterAuth: (s: UserStore): boolean => (s.isSignedIn && enableBetterAuth) || false,
isLoginWithClerk: (s: UserStore): boolean => (s.isSignedIn && enableClerk) || false,
isLoginWithNextAuth: (s: UserStore): boolean => (s.isSignedIn && !!enableNextAuth) || false,
};