@lobehub/lobehub 2.0.0-next.123 → 2.0.0-next.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.cursor/rules/db-migrations.mdc +16 -1
  2. package/.cursor/rules/project-introduce.mdc +1 -1
  3. package/.cursor/rules/project-structure.mdc +20 -2
  4. package/.env.example +148 -65
  5. package/.env.example.development +6 -8
  6. package/AGENTS.md +1 -3
  7. package/CHANGELOG.md +51 -0
  8. package/Dockerfile +6 -6
  9. package/GEMINI.md +63 -0
  10. package/README.md +8 -8
  11. package/README.zh-CN.md +8 -8
  12. package/changelog/v1.json +18 -0
  13. package/docs/development/database-schema.dbml +38 -0
  14. package/docs/self-hosting/advanced/auth.mdx +75 -2
  15. package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -2
  16. package/docs/self-hosting/environment-variables/auth.mdx +187 -1
  17. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
  18. package/locales/en-US/auth.json +93 -0
  19. package/locales/zh-CN/auth.json +107 -1
  20. package/package.json +5 -2
  21. package/packages/const/src/auth.ts +2 -1
  22. package/packages/database/migrations/0048_add_editor_data.sql +1 -0
  23. package/packages/database/migrations/0049_better_auth.sql +49 -0
  24. package/packages/database/migrations/meta/0048_snapshot.json +7913 -0
  25. package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
  26. package/packages/database/migrations/meta/_journal.json +14 -0
  27. package/packages/database/src/core/migrations.json +19 -0
  28. package/packages/database/src/index.ts +1 -0
  29. package/packages/database/src/models/__tests__/session.test.ts +1 -2
  30. package/packages/database/src/models/user.ts +9 -8
  31. package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
  32. package/packages/database/src/schemas/agent.ts +1 -0
  33. package/packages/database/src/schemas/betterAuth.ts +63 -0
  34. package/packages/database/src/schemas/index.ts +1 -0
  35. package/packages/database/src/schemas/ragEvals.ts +1 -2
  36. package/packages/database/src/schemas/user.ts +3 -2
  37. package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
  38. package/packages/types/src/user/preference.ts +11 -0
  39. package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
  40. package/packages/utils/src/server/auth.ts +18 -1
  41. package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
  42. package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
  43. package/src/app/(backend)/middleware/auth/index.ts +14 -0
  44. package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
  45. package/src/app/(backend)/middleware/auth/utils.ts +13 -10
  46. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
  47. package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
  48. package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
  49. package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
  50. package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
  51. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
  52. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
  53. package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
  54. package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
  55. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
  56. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
  57. package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/TopicContent.tsx +15 -8
  58. package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/index.tsx +27 -30
  59. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
  60. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
  61. package/src/auth.ts +118 -0
  62. package/src/components/NextAuth/AuthIcons.tsx +3 -1
  63. package/src/envs/auth.ts +260 -13
  64. package/src/envs/email.ts +37 -0
  65. package/src/features/AgentSetting/AgentPlugin/index.tsx +6 -2
  66. package/src/features/User/UserPanel/PanelContent.tsx +6 -5
  67. package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
  68. package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
  69. package/src/features/User/__tests__/useMenu.test.tsx +14 -12
  70. package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
  71. package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
  72. package/src/layout/AuthProvider/index.tsx +3 -0
  73. package/src/layout/GlobalProvider/StoreInitialization.tsx +3 -3
  74. package/src/libs/better-auth/auth-client.ts +34 -0
  75. package/src/libs/better-auth/constants.ts +13 -0
  76. package/src/libs/better-auth/email-templates/index.ts +3 -0
  77. package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
  78. package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
  79. package/src/libs/better-auth/email-templates/verification.ts +108 -0
  80. package/src/libs/better-auth/sso/helpers.ts +61 -0
  81. package/src/libs/better-auth/sso/index.ts +113 -0
  82. package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
  83. package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
  84. package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
  85. package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
  86. package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
  87. package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
  88. package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
  89. package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
  90. package/src/libs/better-auth/sso/providers/github.ts +30 -0
  91. package/src/libs/better-auth/sso/providers/google.ts +30 -0
  92. package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
  93. package/src/libs/better-auth/sso/providers/logto.ts +38 -0
  94. package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
  95. package/src/libs/better-auth/sso/providers/okta.ts +37 -0
  96. package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
  97. package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
  98. package/src/libs/better-auth/sso/types.ts +25 -0
  99. package/src/libs/better-auth/utils/client.ts +1 -0
  100. package/src/libs/better-auth/utils/common.ts +20 -0
  101. package/src/libs/better-auth/utils/server.test.ts +61 -0
  102. package/src/libs/better-auth/utils/server.ts +18 -0
  103. package/src/libs/trpc/lambda/context.test.ts +116 -0
  104. package/src/libs/trpc/lambda/context.ts +27 -0
  105. package/src/libs/trpc/middleware/userAuth.ts +4 -2
  106. package/src/locales/default/auth.ts +114 -1
  107. package/src/proxy.ts +71 -7
  108. package/src/server/globalConfig/index.ts +12 -1
  109. package/src/server/routers/lambda/user.ts +4 -0
  110. package/src/server/services/email/README.md +241 -0
  111. package/src/server/services/email/impls/index.test.ts +39 -0
  112. package/src/server/services/email/impls/index.ts +32 -0
  113. package/src/server/services/email/impls/nodemailer/index.ts +108 -0
  114. package/src/server/services/email/impls/nodemailer/type.ts +31 -0
  115. package/src/server/services/email/impls/type.ts +61 -0
  116. package/src/server/services/email/index.test.ts +144 -0
  117. package/src/server/services/email/index.ts +40 -0
  118. package/src/services/user/index.test.ts +162 -2
  119. package/src/services/user/index.ts +6 -3
  120. package/src/store/aiInfra/slices/aiProvider/action.ts +4 -4
  121. package/src/store/user/slices/auth/action.test.ts +213 -16
  122. package/src/store/user/slices/auth/action.ts +86 -1
  123. package/src/store/user/slices/auth/initialState.ts +13 -2
  124. package/src/store/user/slices/auth/selectors.ts +6 -2
  125. package/src/store/user/slices/common/action.ts +5 -1
  126. package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@lobehub/ui';
4
+ import { LobeHub } from '@lobehub/ui/brand';
5
+ import { Form, Input } from 'antd';
6
+ import { createStyles } from 'antd-style';
7
+ import { ChevronRight, Lock, Mail } from 'lucide-react';
8
+ import Link from 'next/link';
9
+ import { useRouter, useSearchParams } from 'next/navigation';
10
+ import { useEffect, useState } from 'react';
11
+ import { useTranslation } from 'react-i18next';
12
+ import { Flexbox } from 'react-layout-kit';
13
+
14
+ import { message } from '@/components/AntdStaticMethods';
15
+ import { authEnv } from '@/envs/auth';
16
+ import { signUp } from '@/libs/better-auth/auth-client';
17
+
18
+ const useStyles = createStyles(({ css, token }) => ({
19
+ card: css`
20
+ padding-block: 2.5rem;
21
+ padding-inline: 2rem;
22
+ `,
23
+ container: css`
24
+ width: 360px;
25
+ border: 1px solid ${token.colorBorder};
26
+ border-radius: ${token.borderRadiusLG}px;
27
+ `,
28
+ footer: css`
29
+ padding: 1rem;
30
+ border-block-start: 1px solid ${token.colorBorder};
31
+
32
+ font-size: 14px;
33
+ color: ${token.colorTextDescription};
34
+ text-align: center;
35
+
36
+ background: ${token.colorBgElevated};
37
+ `,
38
+ subtitle: css`
39
+ margin-block-start: 0.5rem;
40
+ font-size: 14px;
41
+ color: ${token.colorTextSecondary};
42
+ text-align: center;
43
+ `,
44
+ title: css`
45
+ margin-block-start: 1rem;
46
+
47
+ font-size: 24px;
48
+ font-weight: 600;
49
+ color: ${token.colorTextHeading};
50
+ text-align: center;
51
+ `,
52
+ }));
53
+
54
+ interface SignUpFormValues {
55
+ email: string;
56
+ password: string;
57
+ }
58
+
59
+ export default function BetterAuthSignUpForm() {
60
+ const { styles } = useStyles();
61
+ const { t } = useTranslation('auth');
62
+ const router = useRouter();
63
+ const searchParams = useSearchParams();
64
+ const [form] = Form.useForm();
65
+ const [loading, setLoading] = useState(false);
66
+
67
+ // Pre-fill email from query params (from signin page redirect)
68
+ useEffect(() => {
69
+ const email = searchParams.get('email');
70
+ if (email) {
71
+ form.setFieldsValue({ email });
72
+ }
73
+ }, [searchParams, form]);
74
+
75
+ const handleSignUp = async (values: SignUpFormValues) => {
76
+ setLoading(true);
77
+ try {
78
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
79
+
80
+ // Generate username from email (use the part before @)
81
+ const username = values.email.split('@')[0];
82
+
83
+ const { error } = await signUp.email({
84
+ callbackURL: callbackUrl,
85
+ email: values.email,
86
+ name: username,
87
+ password: values.password,
88
+ });
89
+
90
+ if (error) {
91
+ message.error(error.message || t('betterAuth.signup.error'));
92
+ return;
93
+ }
94
+
95
+ // Redirect based on email verification requirement
96
+ if (authEnv.NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION) {
97
+ // Email verification required, redirect to verification notice page
98
+ // callbackURL is already passed to signUp.email for verification link
99
+ router.push(
100
+ `/verify-email?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
101
+ );
102
+ } else {
103
+ // Email verification not required, user is already logged in (autoSignIn: true)
104
+ // Redirect to callback URL or home
105
+ router.push(callbackUrl);
106
+ }
107
+ } catch {
108
+ message.error(t('betterAuth.signup.error'));
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ };
113
+
114
+ return (
115
+ <Flexbox align="center" justify="center" style={{ minHeight: '100vh' }}>
116
+ <div className={styles.container}>
117
+ <div className={styles.card}>
118
+ <Flexbox align="center" gap={8} justify="center">
119
+ <LobeHub size={48} />
120
+ </Flexbox>
121
+
122
+ <h1 className={styles.title}>{t('betterAuth.signup.title')}</h1>
123
+ <p className={styles.subtitle}>{t('betterAuth.signup.subtitle')}</p>
124
+
125
+ <Form form={form} layout="vertical" onFinish={handleSignUp} style={{ marginTop: '2rem' }}>
126
+ <Form.Item
127
+ name="email"
128
+ rules={[
129
+ { message: t('betterAuth.errors.emailRequired'), required: true },
130
+ { message: t('betterAuth.errors.emailInvalid'), type: 'email' },
131
+ ]}
132
+ >
133
+ <Input
134
+ placeholder={t('betterAuth.signup.emailPlaceholder')}
135
+ prefix={<Mail size={16} />}
136
+ size="large"
137
+ />
138
+ </Form.Item>
139
+
140
+ <Form.Item
141
+ name="password"
142
+ rules={[
143
+ { message: t('betterAuth.errors.passwordRequired'), required: true },
144
+ { message: t('betterAuth.errors.passwordMinLength'), min: 8 },
145
+ { max: 64, message: t('betterAuth.errors.passwordMaxLength') },
146
+ {
147
+ message: t('betterAuth.errors.passwordFormat'),
148
+ validator: (_, value) => {
149
+ if (!value) return Promise.resolve();
150
+ const hasLetter = /[A-Za-z]/.test(value);
151
+ const hasNumber = /\d/.test(value);
152
+ if (hasLetter && hasNumber) {
153
+ return Promise.resolve();
154
+ }
155
+ return Promise.reject();
156
+ },
157
+ },
158
+ ]}
159
+ >
160
+ <Input.Password
161
+ placeholder={t('betterAuth.signup.passwordPlaceholder')}
162
+ prefix={<Lock size={16} />}
163
+ size="large"
164
+ />
165
+ </Form.Item>
166
+
167
+ <Form.Item>
168
+ <Button
169
+ block
170
+ htmlType="submit"
171
+ icon={<ChevronRight size={16} />}
172
+ iconPosition="end"
173
+ loading={loading}
174
+ size="large"
175
+ type="primary"
176
+ >
177
+ {t('betterAuth.signup.submit')}
178
+ </Button>
179
+ </Form.Item>
180
+ </Form>
181
+ </div>
182
+
183
+ <div className={styles.footer}>
184
+ {t('betterAuth.signup.hasAccount')}{' '}
185
+ <Link href={`/signin?${searchParams.toString()}`}>
186
+ {t('betterAuth.signup.signinLink')}
187
+ </Link>
188
+ </div>
189
+ </div>
190
+ </Flexbox>
191
+ );
192
+ }
@@ -1,26 +1,51 @@
1
1
  import { SignUp } from '@clerk/nextjs';
2
2
  import { notFound } from 'next/navigation';
3
3
 
4
- import { enableClerk } from '@/const/auth';
4
+ import { enableBetterAuth, enableClerk } from '@/const/auth';
5
5
  import { metadataModule } from '@/server/metadata';
6
6
  import { translation } from '@/server/translation';
7
7
  import { DynamicLayoutProps } from '@/types/next';
8
8
  import { RouteVariants } from '@/utils/server/routeVariants';
9
9
 
10
+ import BetterAuthSignUpForm from './BetterAuthSignUpForm';
11
+
10
12
  export const generateMetadata = async (props: DynamicLayoutProps) => {
11
13
  const locale = await RouteVariants.getLocale(props);
12
- const { t } = await translation('clerk', locale);
14
+
15
+ if (enableClerk) {
16
+ const { t } = await translation('clerk', locale);
17
+ return metadataModule.generate({
18
+ description: t('signUp.start.subtitle'),
19
+ title: t('signUp.start.title'),
20
+ url: '/signup',
21
+ });
22
+ }
23
+
24
+ if (enableBetterAuth) {
25
+ const { t } = await translation('auth', locale);
26
+ return metadataModule.generate({
27
+ description: t('betterAuth.signup.subtitle'),
28
+ title: t('betterAuth.signup.title'),
29
+ url: '/signup',
30
+ });
31
+ }
32
+
13
33
  return metadataModule.generate({
14
- description: t('signUp.start.subtitle'),
15
- title: t('signUp.start.title'),
34
+ title: 'Sign Up',
16
35
  url: '/signup',
17
36
  });
18
37
  };
19
38
 
20
39
  const Page = () => {
21
- if (!enableClerk) return notFound();
40
+ if (enableClerk) {
41
+ return <SignUp path="/signup" />;
42
+ }
43
+
44
+ if (enableBetterAuth) {
45
+ return <BetterAuthSignUpForm />;
46
+ }
22
47
 
23
- return <SignUp path="/signup" />;
48
+ return notFound();
24
49
  };
25
50
 
26
51
  Page.displayName = 'SignUp';
@@ -0,0 +1,12 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { PropsWithChildren } from 'react';
3
+
4
+ import { enableBetterAuth } from '@/const/auth';
5
+
6
+ const Layout = ({ children }: PropsWithChildren) => {
7
+ if (!enableBetterAuth) return notFound();
8
+
9
+ return children;
10
+ };
11
+
12
+ export default Layout;
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@lobehub/ui';
4
+ import { LobeHub } from '@lobehub/ui/brand';
5
+ import { createStyles, useTheme } from 'antd-style';
6
+ import { ArrowLeft, Mail, RefreshCw } from 'lucide-react';
7
+ import Link from 'next/link';
8
+ import { useSearchParams } from 'next/navigation';
9
+ import { useState } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { Center, Flexbox } from 'react-layout-kit';
12
+
13
+ import { message } from '@/components/AntdStaticMethods';
14
+ import { sendVerificationEmail } from '@/libs/better-auth/auth-client';
15
+
16
+ const useStyles = createStyles(({ css, token }) => ({
17
+ backLink: css`
18
+ display: inline-flex;
19
+ gap: 6px;
20
+ align-items: center;
21
+
22
+ font-size: 14px;
23
+ color: ${token.colorTextSecondary};
24
+ text-decoration: none;
25
+
26
+ transition: color 0.2s ease;
27
+
28
+ &:hover {
29
+ color: ${token.colorText};
30
+ }
31
+ `,
32
+ container: css`
33
+ max-width: 480px;
34
+ padding: 2rem;
35
+ text-align: center;
36
+ `,
37
+ description: css`
38
+ font-size: 16px;
39
+ line-height: 1.6;
40
+ color: ${token.colorText};
41
+ `,
42
+ hint: css`
43
+ margin-block-start: 0.5rem;
44
+ font-size: 14px;
45
+ color: ${token.colorTextTertiary};
46
+ `,
47
+ iconWrapper: css`
48
+ display: inline-flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+
52
+ width: 96px;
53
+ height: 96px;
54
+ border-radius: 50%;
55
+
56
+ background: linear-gradient(
57
+ 135deg,
58
+ ${token.colorPrimaryBg} 0%,
59
+ ${token.colorPrimaryBgHover} 100%
60
+ );
61
+ `,
62
+ mailLink: css`
63
+ font-weight: 500;
64
+ color: ${token.colorPrimary};
65
+ text-decoration: none;
66
+
67
+ &:hover {
68
+ text-decoration: underline;
69
+ }
70
+ `,
71
+ resendButton: css`
72
+ margin-block-start: 0.5rem;
73
+ `,
74
+ textGroup: css`
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 0.5rem;
78
+ `,
79
+ title: css`
80
+ margin-block: 0;
81
+ font-size: 28px;
82
+ font-weight: 600;
83
+ color: ${token.colorTextHeading};
84
+ `,
85
+ }));
86
+
87
+ export default function VerifyEmailPage() {
88
+ const { styles } = useStyles();
89
+ const theme = useTheme();
90
+ const { t } = useTranslation('auth');
91
+ const searchParams = useSearchParams();
92
+ const email = searchParams.get('email');
93
+ const [resending, setResending] = useState(false);
94
+
95
+ const handleResendEmail = async () => {
96
+ if (!email) {
97
+ message.error(t('betterAuth.verifyEmail.resend.noEmail'));
98
+ return;
99
+ }
100
+
101
+ setResending(true);
102
+ try {
103
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
104
+
105
+ const result = await sendVerificationEmail({
106
+ callbackURL: callbackUrl,
107
+ email,
108
+ });
109
+
110
+ if (result.error) {
111
+ message.error(result.error.message || t('betterAuth.verifyEmail.resend.error'));
112
+ return;
113
+ }
114
+
115
+ message.success(t('betterAuth.verifyEmail.resend.success'));
116
+ } catch (error) {
117
+ console.error('Error resending verification email:', error);
118
+ message.error(t('betterAuth.verifyEmail.resend.error'));
119
+ } finally {
120
+ setResending(false);
121
+ }
122
+ };
123
+
124
+ return (
125
+ <Center style={{ minHeight: '100vh' }}>
126
+ <Flexbox align="center" className={styles.container} gap={24}>
127
+ <LobeHub size={56} />
128
+
129
+ <h1 className={styles.title}>{t('betterAuth.verifyEmail.title')}</h1>
130
+
131
+ <div className={styles.iconWrapper}>
132
+ <Mail color={theme.colorPrimary} size={40} strokeWidth={1.5} />
133
+ </div>
134
+
135
+ <div className={styles.textGroup}>
136
+ <p className={styles.description}>
137
+ {t('betterAuth.verifyEmail.descriptionPrefix')}{' '}
138
+ <a className={styles.mailLink} href={`mailto:${email}`}>
139
+ {email}
140
+ </a>{' '}
141
+ {t('betterAuth.verifyEmail.descriptionSuffix')}
142
+ </p>
143
+ <p className={styles.hint}>{t('betterAuth.verifyEmail.checkSpam')}</p>
144
+ </div>
145
+
146
+ <Button
147
+ className={styles.resendButton}
148
+ icon={<RefreshCw size={16} />}
149
+ loading={resending}
150
+ onClick={handleResendEmail}
151
+ size="middle"
152
+ type="default"
153
+ >
154
+ {t('betterAuth.verifyEmail.resend.button')}
155
+ </Button>
156
+
157
+ <Link className={styles.backLink} href="/signin">
158
+ <ArrowLeft size={16} />
159
+ {t('betterAuth.verifyEmail.backToSignIn')}
160
+ </Link>
161
+ </Flexbox>
162
+ </Center>
163
+ );
164
+ }
@@ -31,22 +31,24 @@ vi.mock('@/const/version', () => ({
31
31
  isDesktop: false,
32
32
  }));
33
33
 
34
- // 定义一个变量来存储 enableAuth 的值
35
- let enableAuth = true;
36
- let enableClerk = false;
34
+ // Use vi.hoisted to ensure variables exist before vi.mock factory executes
35
+ const { enableAuth, enableClerk } = vi.hoisted(() => ({
36
+ enableAuth: { value: true },
37
+ enableClerk: { value: false },
38
+ }));
37
39
 
38
- // 模拟 @/const/auth 模块
39
40
  vi.mock('@/const/auth', () => ({
40
41
  get enableAuth() {
41
- return enableAuth;
42
+ return enableAuth.value;
42
43
  },
43
44
  get enableClerk() {
44
- return enableClerk;
45
+ return enableClerk.value;
45
46
  },
46
47
  }));
47
48
 
48
49
  afterEach(() => {
49
- enableAuth = true;
50
+ enableAuth.value = true;
51
+ enableClerk.value = false;
50
52
  mockNavigate.mockReset();
51
53
  });
52
54
 
@@ -55,7 +57,7 @@ describe('UserBanner', () => {
55
57
  act(() => {
56
58
  useUserStore.setState({ isSignedIn: false });
57
59
  });
58
- enableAuth = false;
60
+ enableAuth.value = false;
59
61
 
60
62
  render(<UserBanner />);
61
63
 
@@ -69,7 +71,7 @@ describe('UserBanner', () => {
69
71
  useUserStore.setState({ isSignedIn: true });
70
72
  });
71
73
 
72
- enableClerk = true;
74
+ enableClerk.value = true;
73
75
 
74
76
  render(<UserBanner />);
75
77
 
@@ -82,7 +84,7 @@ describe('UserBanner', () => {
82
84
  act(() => {
83
85
  useUserStore.setState({ isSignedIn: false });
84
86
  });
85
- enableClerk = true;
87
+ enableClerk.value = true;
86
88
 
87
89
  render(<UserBanner />);
88
90
 
@@ -22,16 +22,18 @@ vi.mock('react-i18next', () => ({
22
22
  })),
23
23
  }));
24
24
 
25
- // 定义一个变量来存储 enableAuth 的值
26
- let enableAuth = true;
27
- let enableClerk = true;
28
- // 模拟 @/const/auth 模块
25
+ // Use vi.hoisted to ensure variables exist before vi.mock factory executes
26
+ const { enableAuth, enableClerk } = vi.hoisted(() => ({
27
+ enableAuth: { value: true },
28
+ enableClerk: { value: true },
29
+ }));
30
+
29
31
  vi.mock('@/const/auth', () => ({
30
32
  get enableAuth() {
31
- return enableAuth;
33
+ return enableAuth.value;
32
34
  },
33
35
  get enableClerk() {
34
- return enableClerk;
36
+ return enableClerk.value;
35
37
  },
36
38
  }));
37
39
 
@@ -45,8 +47,8 @@ vi.mock('@/const/version', async (importOriginal) => {
45
47
  });
46
48
 
47
49
  afterEach(() => {
48
- enableAuth = true;
49
- enableClerk = true;
50
+ enableAuth.value = true;
51
+ enableClerk.value = true;
50
52
  mockNavigate.mockReset();
51
53
  });
52
54
 
@@ -55,8 +57,8 @@ describe('useCategory', () => {
55
57
  act(() => {
56
58
  useUserStore.setState({ isSignedIn: true });
57
59
  });
58
- enableAuth = true;
59
- enableClerk = false;
60
+ enableAuth.value = true;
61
+ enableClerk.value = false;
60
62
 
61
63
  const { result } = renderHook(() => useCategory(), { wrapper });
62
64
 
@@ -74,7 +76,7 @@ describe('useCategory', () => {
74
76
  act(() => {
75
77
  useUserStore.setState({ isSignedIn: false });
76
78
  });
77
- enableAuth = true;
79
+ enableAuth.value = true;
78
80
 
79
81
  const { result } = renderHook(() => useCategory(), { wrapper });
80
82
 
@@ -97,15 +97,15 @@ const TopicContent = memo<TopicContentProps>(({ id, title, fav, showMore }) => {
97
97
  },
98
98
  ...(isDesktop
99
99
  ? [
100
- {
101
- icon: <Icon icon={ExternalLink} />,
102
- key: 'openInNewWindow',
103
- label: t('actions.openInNewWindow'),
104
- onClick: () => {
105
- openTopicInNewWindow(activeId, id);
100
+ {
101
+ icon: <Icon icon={ExternalLink} />,
102
+ key: 'openInNewWindow',
103
+ label: t('actions.openInNewWindow'),
104
+ onClick: () => {
105
+ openTopicInNewWindow(activeId, id);
106
+ },
106
107
  },
107
- },
108
- ]
108
+ ]
109
109
  : []),
110
110
  {
111
111
  type: 'divider',
@@ -210,6 +210,13 @@ const TopicContent = memo<TopicContentProps>(({ id, title, fav, showMore }) => {
210
210
  ) : (
211
211
  <EditableText
212
212
  editing={editing}
213
+ onBlur={(e) => {
214
+ const v = (e.target as HTMLInputElement).value;
215
+ if (title !== v) {
216
+ updateTopicTitle(id, v);
217
+ }
218
+ toggleEditing(false);
219
+ }}
213
220
  onChangeEnd={(v) => {
214
221
  if (title !== v) {
215
222
  updateTopicTitle(id, v);
@@ -3,7 +3,6 @@ import { createStyles } from 'antd-style';
3
3
  import qs from 'query-string';
4
4
  import { Suspense, memo, useState } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
- import { Link } from 'react-router-dom';
7
6
 
8
7
  import { useChatStore } from '@/store/chat';
9
8
  import { useGlobalStore } from '@/store/global';
@@ -54,47 +53,45 @@ export interface ConfigCellProps {
54
53
  const TopicItem = memo<ConfigCellProps>(({ title, active, id, fav, threadId }) => {
55
54
  const { styles, cx } = useStyles();
56
55
  const toggleConfig = useGlobalStore((s) => s.toggleMobileTopic);
57
- const [toggleTopic] = useChatStore((s) => [s.switchTopic]);
56
+ const [toggleTopic, editing] = useChatStore((s) => [s.switchTopic, s.topicRenamingId === id]);
58
57
  const activeId = useSessionStore((s) => s.activeId);
59
58
  const [isHover, setHovering] = useState(false);
60
59
 
61
- const topicUrl = qs.stringifyUrl({
62
- query: id ? { session: activeId, topic: id } : { session: activeId },
63
- url: '/chat',
64
- });
65
-
66
60
  return (
67
61
  <Flexbox style={{ position: 'relative' }}>
68
- <Link
62
+ <Flexbox
63
+ align={'center'}
64
+ className={cx(styles.container, 'topic-item', active && !threadId && styles.active)}
65
+ distribution={'space-between'}
66
+ horizontal
69
67
  onClick={(e) => {
70
- if (e.button === 0 && (e.metaKey || e.ctrlKey)) {
68
+ // 重命名时不切换话题
69
+ if (editing) return;
70
+ // Ctrl/Cmd+点击在新窗口打开
71
+ if (e.button === 0 && (e.metaKey || e.ctrlKey) && id) {
72
+ const topicUrl = qs.stringifyUrl({
73
+ query: { session: activeId, topic: id },
74
+ url: '/chat',
75
+ });
76
+ window.open(topicUrl, '_blank');
71
77
  return;
72
78
  }
73
- e.preventDefault();
74
79
  toggleTopic(id);
75
80
  toggleConfig(false);
76
81
  }}
77
- to={topicUrl}
82
+ onMouseEnter={() => {
83
+ setHovering(true);
84
+ }}
85
+ onMouseLeave={() => {
86
+ setHovering(false);
87
+ }}
78
88
  >
79
- <Flexbox
80
- align={'center'}
81
- className={cx(styles.container, 'topic-item', active && !threadId && styles.active)}
82
- distribution={'space-between'}
83
- horizontal
84
- onMouseEnter={() => {
85
- setHovering(true);
86
- }}
87
- onMouseLeave={() => {
88
- setHovering(false);
89
- }}
90
- >
91
- {!id ? (
92
- <DefaultContent />
93
- ) : (
94
- <TopicContent fav={fav} id={id} showMore={isHover} title={title} />
95
- )}
96
- </Flexbox>
97
- </Link>
89
+ {!id ? (
90
+ <DefaultContent />
91
+ ) : (
92
+ <TopicContent fav={fav} id={id} showMore={isHover} title={title} />
93
+ )}
94
+ </Flexbox>
98
95
  {active && (
99
96
  <Suspense
100
97
  fallback={