mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe02d8097 | |||
| 3eba40de4e | |||
| a67b0e605c | |||
| 8b9e8c3a8e | |||
| 60ceab08d9 | |||
| 85e5a40666 | |||
| a1b741d94d | |||
| fbf76c8b8f | |||
| 848f527abd | |||
| 0a56fc05e7 | |||
| f61eb76ccf | |||
| 9b2298e796 | |||
| 8e3ab6d980 | |||
| efc0ca8ca2 | |||
| e22cf007bf | |||
| b1c570bf80 | |||
| 96040dc3bf | |||
| b2c22a3df6 | |||
| 5869bbacf3 | |||
| f0f21a6b0d | |||
| 0ce3fa32b4 | |||
| 599eb0187a | |||
| 5eaf34472c | |||
| 0a7eee86f2 |
@@ -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:
|
||||
|
||||
|
||||
@@ -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 #############
|
||||
########################################
|
||||
|
||||
@@ -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()`]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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天"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +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(),
|
||||
});
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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}.`,
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user