@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.
- package/.cursor/rules/db-migrations.mdc +16 -1
- package/.cursor/rules/project-introduce.mdc +1 -1
- package/.cursor/rules/project-structure.mdc +20 -2
- package/.env.example +148 -65
- package/.env.example.development +6 -8
- package/AGENTS.md +1 -3
- package/CHANGELOG.md +51 -0
- package/Dockerfile +6 -6
- package/GEMINI.md +63 -0
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +38 -0
- package/docs/self-hosting/advanced/auth.mdx +75 -2
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -2
- package/docs/self-hosting/environment-variables/auth.mdx +187 -1
- package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
- package/locales/en-US/auth.json +93 -0
- package/locales/zh-CN/auth.json +107 -1
- package/package.json +5 -2
- package/packages/const/src/auth.ts +2 -1
- package/packages/database/migrations/0048_add_editor_data.sql +1 -0
- package/packages/database/migrations/0049_better_auth.sql +49 -0
- package/packages/database/migrations/meta/0048_snapshot.json +7913 -0
- package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
- package/packages/database/migrations/meta/_journal.json +14 -0
- package/packages/database/src/core/migrations.json +19 -0
- package/packages/database/src/index.ts +1 -0
- package/packages/database/src/models/__tests__/session.test.ts +1 -2
- package/packages/database/src/models/user.ts +9 -8
- package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
- package/packages/database/src/schemas/agent.ts +1 -0
- package/packages/database/src/schemas/betterAuth.ts +63 -0
- package/packages/database/src/schemas/index.ts +1 -0
- package/packages/database/src/schemas/ragEvals.ts +1 -2
- package/packages/database/src/schemas/user.ts +3 -2
- package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
- package/packages/types/src/user/preference.ts +11 -0
- package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
- package/packages/utils/src/server/auth.ts +18 -1
- package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
- package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
- package/src/app/(backend)/middleware/auth/index.ts +14 -0
- package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
- package/src/app/(backend)/middleware/auth/utils.ts +13 -10
- package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
- package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
- package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
- package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
- package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/TopicContent.tsx +15 -8
- package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/index.tsx +27 -30
- package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
- package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
- package/src/auth.ts +118 -0
- package/src/components/NextAuth/AuthIcons.tsx +3 -1
- package/src/envs/auth.ts +260 -13
- package/src/envs/email.ts +37 -0
- package/src/features/AgentSetting/AgentPlugin/index.tsx +6 -2
- package/src/features/User/UserPanel/PanelContent.tsx +6 -5
- package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
- package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
- package/src/features/User/__tests__/useMenu.test.tsx +14 -12
- package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
- package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
- package/src/layout/AuthProvider/index.tsx +3 -0
- package/src/layout/GlobalProvider/StoreInitialization.tsx +3 -3
- package/src/libs/better-auth/auth-client.ts +34 -0
- package/src/libs/better-auth/constants.ts +13 -0
- package/src/libs/better-auth/email-templates/index.ts +3 -0
- package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
- package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
- package/src/libs/better-auth/email-templates/verification.ts +108 -0
- package/src/libs/better-auth/sso/helpers.ts +61 -0
- package/src/libs/better-auth/sso/index.ts +113 -0
- package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
- package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
- package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
- package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
- package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
- package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
- package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
- package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
- package/src/libs/better-auth/sso/providers/github.ts +30 -0
- package/src/libs/better-auth/sso/providers/google.ts +30 -0
- package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
- package/src/libs/better-auth/sso/providers/logto.ts +38 -0
- package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
- package/src/libs/better-auth/sso/providers/okta.ts +37 -0
- package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
- package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
- package/src/libs/better-auth/sso/types.ts +25 -0
- package/src/libs/better-auth/utils/client.ts +1 -0
- package/src/libs/better-auth/utils/common.ts +20 -0
- package/src/libs/better-auth/utils/server.test.ts +61 -0
- package/src/libs/better-auth/utils/server.ts +18 -0
- package/src/libs/trpc/lambda/context.test.ts +116 -0
- package/src/libs/trpc/lambda/context.ts +27 -0
- package/src/libs/trpc/middleware/userAuth.ts +4 -2
- package/src/locales/default/auth.ts +114 -1
- package/src/proxy.ts +71 -7
- package/src/server/globalConfig/index.ts +12 -1
- package/src/server/routers/lambda/user.ts +4 -0
- package/src/server/services/email/README.md +241 -0
- package/src/server/services/email/impls/index.test.ts +39 -0
- package/src/server/services/email/impls/index.ts +32 -0
- package/src/server/services/email/impls/nodemailer/index.ts +108 -0
- package/src/server/services/email/impls/nodemailer/type.ts +31 -0
- package/src/server/services/email/impls/type.ts +61 -0
- package/src/server/services/email/index.test.ts +144 -0
- package/src/server/services/email/index.ts +40 -0
- package/src/services/user/index.test.ts +162 -2
- package/src/services/user/index.ts +6 -3
- package/src/store/aiInfra/slices/aiProvider/action.ts +4 -4
- package/src/store/user/slices/auth/action.test.ts +213 -16
- package/src/store/user/slices/auth/action.ts +86 -1
- package/src/store/user/slices/auth/initialState.ts +13 -2
- package/src/store/user/slices/auth/selectors.ts +6 -2
- package/src/store/user/slices/common/action.ts +5 -1
- package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { EmailImplType, createEmailServiceImpl } from './impls';
|
|
4
|
+
import { EmailService } from './index';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
vi.mock('./impls');
|
|
8
|
+
|
|
9
|
+
describe('EmailService', () => {
|
|
10
|
+
let emailService: EmailService;
|
|
11
|
+
let mockEmailImpl: ReturnType<typeof createMockEmailImpl>;
|
|
12
|
+
|
|
13
|
+
function createMockEmailImpl() {
|
|
14
|
+
return {
|
|
15
|
+
sendMail: vi.fn(),
|
|
16
|
+
verify: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
mockEmailImpl = createMockEmailImpl();
|
|
23
|
+
vi.mocked(createEmailServiceImpl).mockReturnValue(mockEmailImpl as any);
|
|
24
|
+
emailService = new EmailService();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('constructor', () => {
|
|
28
|
+
it('should create instance with default email implementation', () => {
|
|
29
|
+
expect(createEmailServiceImpl).toHaveBeenCalledWith(undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should create instance with specified implementation type', () => {
|
|
33
|
+
emailService = new EmailService(EmailImplType.Nodemailer);
|
|
34
|
+
expect(createEmailServiceImpl).toHaveBeenCalledWith(EmailImplType.Nodemailer);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('sendMail', () => {
|
|
39
|
+
it('should call emailImpl.sendMail with correct payload', async () => {
|
|
40
|
+
const mockResponse = {
|
|
41
|
+
messageId: 'test-message-id',
|
|
42
|
+
previewUrl: 'https://ethereal.email/message/xxx',
|
|
43
|
+
};
|
|
44
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
45
|
+
|
|
46
|
+
const payload = {
|
|
47
|
+
from: 'sender@example.com',
|
|
48
|
+
html: '<p>Hello world</p>',
|
|
49
|
+
subject: 'Test Email',
|
|
50
|
+
text: 'Hello world',
|
|
51
|
+
to: 'recipient@example.com',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await emailService.sendMail(payload);
|
|
55
|
+
|
|
56
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
57
|
+
expect(result).toBe(mockResponse);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should support multiple recipients', async () => {
|
|
61
|
+
const mockResponse = {
|
|
62
|
+
messageId: 'test-message-id',
|
|
63
|
+
};
|
|
64
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
65
|
+
|
|
66
|
+
const payload = {
|
|
67
|
+
from: 'sender@example.com',
|
|
68
|
+
subject: 'Test Email',
|
|
69
|
+
text: 'Hello world',
|
|
70
|
+
to: ['recipient1@example.com', 'recipient2@example.com'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await emailService.sendMail(payload);
|
|
74
|
+
|
|
75
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should support attachments', async () => {
|
|
79
|
+
const mockResponse = {
|
|
80
|
+
messageId: 'test-message-id',
|
|
81
|
+
};
|
|
82
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
83
|
+
|
|
84
|
+
const payload = {
|
|
85
|
+
attachments: [
|
|
86
|
+
{
|
|
87
|
+
content: Buffer.from('test content'),
|
|
88
|
+
filename: 'test.txt',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
from: 'sender@example.com',
|
|
92
|
+
subject: 'Test Email',
|
|
93
|
+
text: 'Hello world',
|
|
94
|
+
to: 'recipient@example.com',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await emailService.sendMail(payload);
|
|
98
|
+
|
|
99
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should support reply-to address', async () => {
|
|
103
|
+
const mockResponse = {
|
|
104
|
+
messageId: 'test-message-id',
|
|
105
|
+
};
|
|
106
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
107
|
+
|
|
108
|
+
const payload = {
|
|
109
|
+
from: 'noreply@example.com',
|
|
110
|
+
replyTo: 'support@example.com',
|
|
111
|
+
subject: 'Test Email',
|
|
112
|
+
text: 'Hello world',
|
|
113
|
+
to: 'recipient@example.com',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
await emailService.sendMail(payload);
|
|
117
|
+
|
|
118
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('verify', () => {
|
|
123
|
+
it('should call emailImpl.verify if available', async () => {
|
|
124
|
+
mockEmailImpl.verify.mockResolvedValue(true);
|
|
125
|
+
|
|
126
|
+
const result = await emailService.verify();
|
|
127
|
+
|
|
128
|
+
expect(mockEmailImpl.verify).toHaveBeenCalled();
|
|
129
|
+
expect(result).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return true if verify method is not available', async () => {
|
|
133
|
+
const mockImplWithoutVerify = {
|
|
134
|
+
sendMail: vi.fn(),
|
|
135
|
+
};
|
|
136
|
+
vi.mocked(createEmailServiceImpl).mockReturnValue(mockImplWithoutVerify as any);
|
|
137
|
+
emailService = new EmailService();
|
|
138
|
+
|
|
139
|
+
const result = await emailService.verify();
|
|
140
|
+
|
|
141
|
+
expect(result).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
import { EmailImplType, EmailPayload, EmailResponse, createEmailServiceImpl } from './impls';
|
|
3
|
+
import type { EmailServiceImpl } from './impls';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Email service class
|
|
7
|
+
* Provides email sending functionality with multiple provider support
|
|
8
|
+
*/
|
|
9
|
+
export class EmailService {
|
|
10
|
+
private emailImpl: EmailServiceImpl;
|
|
11
|
+
|
|
12
|
+
constructor(implType?: EmailImplType) {
|
|
13
|
+
this.emailImpl = createEmailServiceImpl(implType);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send an email
|
|
18
|
+
*/
|
|
19
|
+
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
|
|
20
|
+
return this.emailImpl.sendMail(payload);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verify the email service configuration
|
|
25
|
+
* Note: Only available for Nodemailer implementation
|
|
26
|
+
*/
|
|
27
|
+
async verify(): Promise<boolean> {
|
|
28
|
+
// Check if the implementation has a verify method
|
|
29
|
+
if ('verify' in this.emailImpl && typeof this.emailImpl.verify === 'function') {
|
|
30
|
+
return this.emailImpl.verify();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// For implementations without verify, assume it's valid
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Export types
|
|
39
|
+
export type { EmailPayload, EmailResponse } from './impls';
|
|
40
|
+
export { EmailImplType } from './impls';
|
|
@@ -1,8 +1,168 @@
|
|
|
1
|
-
import { describe } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { testService } from '~test-utils';
|
|
3
3
|
|
|
4
|
-
import { UserService } from './index';
|
|
4
|
+
import { UserService, userService } from './index';
|
|
5
|
+
|
|
6
|
+
const mockLambdaClient = vi.hoisted(() => ({
|
|
7
|
+
user: {
|
|
8
|
+
getUserRegistrationDuration: { query: vi.fn() },
|
|
9
|
+
getUserState: { query: vi.fn() },
|
|
10
|
+
getUserSSOProviders: { query: vi.fn() },
|
|
11
|
+
unlinkSSOProvider: { mutate: vi.fn() },
|
|
12
|
+
makeUserOnboarded: { mutate: vi.fn() },
|
|
13
|
+
updateAvatar: { mutate: vi.fn() },
|
|
14
|
+
updateFullName: { mutate: vi.fn() },
|
|
15
|
+
updatePreference: { mutate: vi.fn() },
|
|
16
|
+
updateGuide: { mutate: vi.fn() },
|
|
17
|
+
updateSettings: { mutate: vi.fn() },
|
|
18
|
+
resetSettings: { mutate: vi.fn() },
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@/libs/trpc/client', () => ({
|
|
23
|
+
lambdaClient: mockLambdaClient,
|
|
24
|
+
}));
|
|
5
25
|
|
|
6
26
|
describe('UserService', () => {
|
|
7
27
|
testService(UserService);
|
|
28
|
+
|
|
29
|
+
describe('getUserRegistrationDuration', () => {
|
|
30
|
+
it('should call lambdaClient.user.getUserRegistrationDuration.query', async () => {
|
|
31
|
+
const mockResult = { createdAt: '2024-01-01', duration: 100, updatedAt: '2024-01-02' };
|
|
32
|
+
mockLambdaClient.user.getUserRegistrationDuration.query.mockResolvedValueOnce(mockResult);
|
|
33
|
+
|
|
34
|
+
const result = await userService.getUserRegistrationDuration();
|
|
35
|
+
|
|
36
|
+
expect(mockLambdaClient.user.getUserRegistrationDuration.query).toHaveBeenCalled();
|
|
37
|
+
expect(result).toEqual(mockResult);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getUserState', () => {
|
|
42
|
+
it('should call lambdaClient.user.getUserState.query', async () => {
|
|
43
|
+
const mockState = { isOnboarded: true, preference: {}, settings: {} };
|
|
44
|
+
mockLambdaClient.user.getUserState.query.mockResolvedValueOnce(mockState);
|
|
45
|
+
|
|
46
|
+
const result = await userService.getUserState();
|
|
47
|
+
|
|
48
|
+
expect(mockLambdaClient.user.getUserState.query).toHaveBeenCalled();
|
|
49
|
+
expect(result).toEqual(mockState);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getUserSSOProviders', () => {
|
|
54
|
+
it('should call lambdaClient.user.getUserSSOProviders.query', async () => {
|
|
55
|
+
const mockProviders = [
|
|
56
|
+
{ provider: 'github', email: 'test@example.com', providerAccountId: '123' },
|
|
57
|
+
];
|
|
58
|
+
mockLambdaClient.user.getUserSSOProviders.query.mockResolvedValueOnce(mockProviders);
|
|
59
|
+
|
|
60
|
+
const result = await userService.getUserSSOProviders();
|
|
61
|
+
|
|
62
|
+
expect(mockLambdaClient.user.getUserSSOProviders.query).toHaveBeenCalled();
|
|
63
|
+
expect(result).toEqual(mockProviders);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('unlinkSSOProvider', () => {
|
|
68
|
+
it('should call lambdaClient.user.unlinkSSOProvider.mutate with correct params', async () => {
|
|
69
|
+
mockLambdaClient.user.unlinkSSOProvider.mutate.mockResolvedValueOnce({ success: true });
|
|
70
|
+
|
|
71
|
+
await userService.unlinkSSOProvider('github', 'account-123');
|
|
72
|
+
|
|
73
|
+
expect(mockLambdaClient.user.unlinkSSOProvider.mutate).toHaveBeenCalledWith({
|
|
74
|
+
provider: 'github',
|
|
75
|
+
providerAccountId: 'account-123',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('makeUserOnboarded', () => {
|
|
81
|
+
it('should call lambdaClient.user.makeUserOnboarded.mutate', async () => {
|
|
82
|
+
mockLambdaClient.user.makeUserOnboarded.mutate.mockResolvedValueOnce({ success: true });
|
|
83
|
+
|
|
84
|
+
await userService.makeUserOnboarded();
|
|
85
|
+
|
|
86
|
+
expect(mockLambdaClient.user.makeUserOnboarded.mutate).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('updateAvatar', () => {
|
|
91
|
+
it('should call lambdaClient.user.updateAvatar.mutate with avatar string', async () => {
|
|
92
|
+
mockLambdaClient.user.updateAvatar.mutate.mockResolvedValueOnce({ success: true });
|
|
93
|
+
|
|
94
|
+
await userService.updateAvatar('https://example.com/avatar.png');
|
|
95
|
+
|
|
96
|
+
expect(mockLambdaClient.user.updateAvatar.mutate).toHaveBeenCalledWith(
|
|
97
|
+
'https://example.com/avatar.png',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('updateFullName', () => {
|
|
103
|
+
it('should call lambdaClient.user.updateFullName.mutate with fullName string', async () => {
|
|
104
|
+
mockLambdaClient.user.updateFullName.mutate.mockResolvedValueOnce({ success: true });
|
|
105
|
+
|
|
106
|
+
await userService.updateFullName('John Doe');
|
|
107
|
+
|
|
108
|
+
expect(mockLambdaClient.user.updateFullName.mutate).toHaveBeenCalledWith('John Doe');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('updatePreference', () => {
|
|
113
|
+
it('should call lambdaClient.user.updatePreference.mutate with preference object', async () => {
|
|
114
|
+
const preference = { hideSyncAlert: true };
|
|
115
|
+
mockLambdaClient.user.updatePreference.mutate.mockResolvedValueOnce({ success: true });
|
|
116
|
+
|
|
117
|
+
await userService.updatePreference(preference);
|
|
118
|
+
|
|
119
|
+
expect(mockLambdaClient.user.updatePreference.mutate).toHaveBeenCalledWith(preference);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('updateGuide', () => {
|
|
124
|
+
it('should call lambdaClient.user.updateGuide.mutate with guide object', async () => {
|
|
125
|
+
const guide = { moveSettingsToAvatar: true };
|
|
126
|
+
mockLambdaClient.user.updateGuide.mutate.mockResolvedValueOnce({ success: true });
|
|
127
|
+
|
|
128
|
+
await userService.updateGuide(guide);
|
|
129
|
+
|
|
130
|
+
expect(mockLambdaClient.user.updateGuide.mutate).toHaveBeenCalledWith(guide);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('updateUserSettings', () => {
|
|
135
|
+
it('should call lambdaClient.user.updateSettings.mutate with settings', async () => {
|
|
136
|
+
const settings = { general: { fontSize: 14 } };
|
|
137
|
+
mockLambdaClient.user.updateSettings.mutate.mockResolvedValueOnce({ success: true });
|
|
138
|
+
|
|
139
|
+
await userService.updateUserSettings(settings);
|
|
140
|
+
|
|
141
|
+
expect(mockLambdaClient.user.updateSettings.mutate).toHaveBeenCalledWith(settings, {
|
|
142
|
+
signal: undefined,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should pass abort signal when provided', async () => {
|
|
147
|
+
const settings = { general: { fontSize: 16 } };
|
|
148
|
+
const abortController = new AbortController();
|
|
149
|
+
mockLambdaClient.user.updateSettings.mutate.mockResolvedValueOnce({ success: true });
|
|
150
|
+
|
|
151
|
+
await userService.updateUserSettings(settings, abortController.signal);
|
|
152
|
+
|
|
153
|
+
expect(mockLambdaClient.user.updateSettings.mutate).toHaveBeenCalledWith(settings, {
|
|
154
|
+
signal: abortController.signal,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('resetUserSettings', () => {
|
|
160
|
+
it('should call lambdaClient.user.resetSettings.mutate', async () => {
|
|
161
|
+
mockLambdaClient.user.resetSettings.mutate.mockResolvedValueOnce({ success: true });
|
|
162
|
+
|
|
163
|
+
await userService.resetUserSettings();
|
|
164
|
+
|
|
165
|
+
expect(mockLambdaClient.user.resetSettings.mutate).toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
8
168
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { AdapterAccount } from 'next-auth/adapters';
|
|
2
1
|
import type { PartialDeep } from 'type-fest';
|
|
3
2
|
|
|
4
3
|
import { lambdaClient } from '@/libs/trpc/client';
|
|
5
|
-
import { UserGuide, UserInitializationState, UserPreference } from '@/types/user';
|
|
4
|
+
import { SSOProvider, UserGuide, UserInitializationState, UserPreference } from '@/types/user';
|
|
6
5
|
import { UserSettings } from '@/types/user/settings';
|
|
7
6
|
|
|
8
7
|
export class UserService {
|
|
@@ -18,7 +17,7 @@ export class UserService {
|
|
|
18
17
|
return lambdaClient.user.getUserState.query();
|
|
19
18
|
};
|
|
20
19
|
|
|
21
|
-
getUserSSOProviders = async (): Promise<
|
|
20
|
+
getUserSSOProviders = async (): Promise<SSOProvider[]> => {
|
|
22
21
|
return lambdaClient.user.getUserSSOProviders.query();
|
|
23
22
|
};
|
|
24
23
|
|
|
@@ -34,6 +33,10 @@ export class UserService {
|
|
|
34
33
|
return lambdaClient.user.updateAvatar.mutate(avatar);
|
|
35
34
|
};
|
|
36
35
|
|
|
36
|
+
updateFullName = async (fullName: string) => {
|
|
37
|
+
return lambdaClient.user.updateFullName.mutate(fullName);
|
|
38
|
+
};
|
|
39
|
+
|
|
37
40
|
updatePreference = async (preference: Partial<UserPreference>) => {
|
|
38
41
|
return lambdaClient.user.updatePreference.mutate(preference);
|
|
39
42
|
};
|
|
@@ -77,10 +77,10 @@ export const normalizeImageModel = async (
|
|
|
77
77
|
const fallbackParametersPromise = model.parameters
|
|
78
78
|
? Promise.resolve<ModelParamsSchema | undefined>(model.parameters)
|
|
79
79
|
: getModelPropertyWithFallback<ModelParamsSchema | undefined>(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
model.id,
|
|
81
|
+
'parameters',
|
|
82
|
+
model.providerId,
|
|
83
|
+
);
|
|
84
84
|
|
|
85
85
|
const modelWithPricing = model as AIImageModelCard;
|
|
86
86
|
const fallbackPricingPromise = modelWithPricing.pricing
|
|
@@ -14,26 +14,60 @@ vi.mock('swr', async (importOriginal) => {
|
|
|
14
14
|
};
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
// Use vi.hoisted to ensure variables exist before vi.mock factory executes
|
|
18
|
+
const { enableClerk, enableNextAuth, enableBetterAuth, enableAuth } = vi.hoisted(() => ({
|
|
19
|
+
enableClerk: { value: false },
|
|
20
|
+
enableNextAuth: { value: false },
|
|
21
|
+
enableBetterAuth: { value: false },
|
|
22
|
+
enableAuth: { value: true },
|
|
23
|
+
}));
|
|
21
24
|
|
|
22
|
-
// 模拟 @/const/auth 模块
|
|
23
25
|
vi.mock('@/const/auth', () => ({
|
|
24
26
|
get enableClerk() {
|
|
25
|
-
return enableClerk;
|
|
27
|
+
return enableClerk.value;
|
|
26
28
|
},
|
|
27
29
|
get enableNextAuth() {
|
|
28
|
-
return enableNextAuth;
|
|
30
|
+
return enableNextAuth.value;
|
|
31
|
+
},
|
|
32
|
+
get enableBetterAuth() {
|
|
33
|
+
return enableBetterAuth.value;
|
|
34
|
+
},
|
|
35
|
+
get enableAuth() {
|
|
36
|
+
return enableAuth.value;
|
|
29
37
|
},
|
|
30
38
|
}));
|
|
31
39
|
|
|
40
|
+
const mockUserService = vi.hoisted(() => ({
|
|
41
|
+
getUserSSOProviders: vi.fn().mockResolvedValue([]),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('@/services/user', () => ({
|
|
45
|
+
userService: mockUserService,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const mockBetterAuthClient = vi.hoisted(() => ({
|
|
49
|
+
listAccounts: vi.fn().mockResolvedValue({ data: [] }),
|
|
50
|
+
accountInfo: vi.fn().mockResolvedValue({ data: { user: {} } }),
|
|
51
|
+
signOut: vi.fn().mockResolvedValue({}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('@/libs/better-auth/auth-client', () => mockBetterAuthClient);
|
|
55
|
+
|
|
32
56
|
afterEach(() => {
|
|
33
57
|
vi.restoreAllMocks();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
|
|
60
|
+
enableNextAuth.value = false;
|
|
61
|
+
enableClerk.value = false;
|
|
62
|
+
enableBetterAuth.value = false;
|
|
63
|
+
enableAuth.value = true;
|
|
64
|
+
|
|
65
|
+
// Reset store state
|
|
66
|
+
useUserStore.setState({
|
|
67
|
+
isLoadedAuthProviders: false,
|
|
68
|
+
authProviders: [],
|
|
69
|
+
isEmailPasswordAuth: false,
|
|
70
|
+
});
|
|
37
71
|
});
|
|
38
72
|
|
|
39
73
|
/**
|
|
@@ -61,7 +95,7 @@ describe('createAuthSlice', () => {
|
|
|
61
95
|
|
|
62
96
|
describe('logout', () => {
|
|
63
97
|
it('should call clerkSignOut when Clerk is enabled', async () => {
|
|
64
|
-
enableClerk = true;
|
|
98
|
+
enableClerk.value = true;
|
|
65
99
|
|
|
66
100
|
const clerkSignOutMock = vi.fn();
|
|
67
101
|
useUserStore.setState({ clerkSignOut: clerkSignOutMock });
|
|
@@ -89,7 +123,7 @@ describe('createAuthSlice', () => {
|
|
|
89
123
|
});
|
|
90
124
|
|
|
91
125
|
it('should call next-auth signOut when NextAuth is enabled', async () => {
|
|
92
|
-
enableNextAuth = true;
|
|
126
|
+
enableNextAuth.value = true;
|
|
93
127
|
|
|
94
128
|
const { result } = renderHook(() => useUserStore());
|
|
95
129
|
|
|
@@ -100,7 +134,7 @@ describe('createAuthSlice', () => {
|
|
|
100
134
|
const { signOut } = await import('next-auth/react');
|
|
101
135
|
|
|
102
136
|
expect(signOut).toHaveBeenCalled();
|
|
103
|
-
enableNextAuth = false;
|
|
137
|
+
enableNextAuth.value = false;
|
|
104
138
|
});
|
|
105
139
|
|
|
106
140
|
it('should not call next-auth signOut when NextAuth is disabled', async () => {
|
|
@@ -118,7 +152,7 @@ describe('createAuthSlice', () => {
|
|
|
118
152
|
|
|
119
153
|
describe('openLogin', () => {
|
|
120
154
|
it('should call clerkSignIn when Clerk is enabled', async () => {
|
|
121
|
-
enableClerk = true;
|
|
155
|
+
enableClerk.value = true;
|
|
122
156
|
const clerkSignInMock = vi.fn();
|
|
123
157
|
useUserStore.setState({ clerkSignIn: clerkSignInMock });
|
|
124
158
|
|
|
@@ -144,7 +178,7 @@ describe('createAuthSlice', () => {
|
|
|
144
178
|
});
|
|
145
179
|
|
|
146
180
|
it('should call next-auth signIn when NextAuth is enabled', async () => {
|
|
147
|
-
enableNextAuth = true;
|
|
181
|
+
enableNextAuth.value = true;
|
|
148
182
|
|
|
149
183
|
const { result } = renderHook(() => useUserStore());
|
|
150
184
|
|
|
@@ -155,7 +189,7 @@ describe('createAuthSlice', () => {
|
|
|
155
189
|
const { signIn } = await import('next-auth/react');
|
|
156
190
|
|
|
157
191
|
expect(signIn).toHaveBeenCalled();
|
|
158
|
-
enableNextAuth = false;
|
|
192
|
+
enableNextAuth.value = false;
|
|
159
193
|
});
|
|
160
194
|
it('should not call next-auth signIn when NextAuth is disabled', async () => {
|
|
161
195
|
const { result } = renderHook(() => useUserStore());
|
|
@@ -168,5 +202,168 @@ describe('createAuthSlice', () => {
|
|
|
168
202
|
|
|
169
203
|
expect(signIn).not.toHaveBeenCalled();
|
|
170
204
|
});
|
|
205
|
+
|
|
206
|
+
it('should redirect to signin page when BetterAuth is enabled', async () => {
|
|
207
|
+
enableBetterAuth.value = true;
|
|
208
|
+
|
|
209
|
+
const originalLocation = window.location;
|
|
210
|
+
Object.defineProperty(window, 'location', {
|
|
211
|
+
configurable: true,
|
|
212
|
+
value: { ...originalLocation, href: '', toString: () => 'http://localhost/chat' },
|
|
213
|
+
writable: true,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const { result } = renderHook(() => useUserStore());
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
await result.current.openLogin();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(window.location.href).toContain('/signin');
|
|
223
|
+
expect(window.location.href).toContain('callbackUrl');
|
|
224
|
+
|
|
225
|
+
Object.defineProperty(window, 'location', {
|
|
226
|
+
configurable: true,
|
|
227
|
+
value: originalLocation,
|
|
228
|
+
writable: true,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should call signIn with single provider when only one OAuth provider available', async () => {
|
|
233
|
+
enableNextAuth.value = true;
|
|
234
|
+
useUserStore.setState({ oAuthSSOProviders: ['github'] });
|
|
235
|
+
|
|
236
|
+
const { result } = renderHook(() => useUserStore());
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
await result.current.openLogin();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const { signIn } = await import('next-auth/react');
|
|
243
|
+
|
|
244
|
+
expect(signIn).toHaveBeenCalledWith('github');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('enableAuth', () => {
|
|
249
|
+
it('should return true when auth is enabled', () => {
|
|
250
|
+
enableAuth.value = true;
|
|
251
|
+
const { result } = renderHook(() => useUserStore());
|
|
252
|
+
|
|
253
|
+
expect(result.current.enableAuth()).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should return false when auth is disabled', () => {
|
|
257
|
+
enableAuth.value = false;
|
|
258
|
+
const { result } = renderHook(() => useUserStore());
|
|
259
|
+
|
|
260
|
+
expect(result.current.enableAuth()).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('fetchAuthProviders', () => {
|
|
265
|
+
it('should skip fetching if already loaded', async () => {
|
|
266
|
+
useUserStore.setState({ isLoadedAuthProviders: true });
|
|
267
|
+
|
|
268
|
+
const { result } = renderHook(() => useUserStore());
|
|
269
|
+
|
|
270
|
+
await act(async () => {
|
|
271
|
+
await result.current.fetchAuthProviders();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(mockUserService.getUserSSOProviders).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should fetch providers from NextAuth when BetterAuth is disabled', async () => {
|
|
278
|
+
enableBetterAuth.value = false;
|
|
279
|
+
const mockProviders = [
|
|
280
|
+
{ provider: 'github', email: 'test@example.com', providerAccountId: '123' },
|
|
281
|
+
];
|
|
282
|
+
mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders);
|
|
283
|
+
|
|
284
|
+
const { result } = renderHook(() => useUserStore());
|
|
285
|
+
|
|
286
|
+
await act(async () => {
|
|
287
|
+
await result.current.fetchAuthProviders();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(mockUserService.getUserSSOProviders).toHaveBeenCalled();
|
|
291
|
+
expect(result.current.isLoadedAuthProviders).toBe(true);
|
|
292
|
+
expect(result.current.authProviders).toEqual(mockProviders);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should fetch providers from BetterAuth when enabled', async () => {
|
|
296
|
+
enableBetterAuth.value = true;
|
|
297
|
+
mockBetterAuthClient.listAccounts.mockResolvedValueOnce({
|
|
298
|
+
data: [
|
|
299
|
+
{ providerId: 'github', accountId: 'gh-123' },
|
|
300
|
+
{ providerId: 'credential', accountId: 'cred-1' },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
mockBetterAuthClient.accountInfo.mockResolvedValueOnce({
|
|
304
|
+
data: { user: { email: 'test@github.com' } },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const { result } = renderHook(() => useUserStore());
|
|
308
|
+
|
|
309
|
+
await act(async () => {
|
|
310
|
+
await result.current.fetchAuthProviders();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(mockBetterAuthClient.listAccounts).toHaveBeenCalled();
|
|
314
|
+
expect(result.current.isLoadedAuthProviders).toBe(true);
|
|
315
|
+
expect(result.current.isEmailPasswordAuth).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should handle fetch error gracefully', async () => {
|
|
319
|
+
enableBetterAuth.value = false;
|
|
320
|
+
mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Network error'));
|
|
321
|
+
|
|
322
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
323
|
+
|
|
324
|
+
const { result } = renderHook(() => useUserStore());
|
|
325
|
+
|
|
326
|
+
await act(async () => {
|
|
327
|
+
await result.current.fetchAuthProviders();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result.current.isLoadedAuthProviders).toBe(true);
|
|
331
|
+
consoleSpy.mockRestore();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('refreshAuthProviders', () => {
|
|
336
|
+
it('should refresh providers from NextAuth', async () => {
|
|
337
|
+
enableBetterAuth.value = false;
|
|
338
|
+
const mockProviders = [
|
|
339
|
+
{ provider: 'google', email: 'user@gmail.com', providerAccountId: 'g-1' },
|
|
340
|
+
];
|
|
341
|
+
mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders);
|
|
342
|
+
|
|
343
|
+
const { result } = renderHook(() => useUserStore());
|
|
344
|
+
|
|
345
|
+
await act(async () => {
|
|
346
|
+
await result.current.refreshAuthProviders();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(mockUserService.getUserSSOProviders).toHaveBeenCalled();
|
|
350
|
+
expect(result.current.authProviders).toEqual(mockProviders);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should handle refresh error gracefully', async () => {
|
|
354
|
+
enableBetterAuth.value = false;
|
|
355
|
+
mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Refresh failed'));
|
|
356
|
+
|
|
357
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
358
|
+
|
|
359
|
+
const { result } = renderHook(() => useUserStore());
|
|
360
|
+
|
|
361
|
+
await act(async () => {
|
|
362
|
+
await result.current.refreshAuthProviders();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Should not throw
|
|
366
|
+
consoleSpy.mockRestore();
|
|
367
|
+
});
|
|
171
368
|
});
|
|
172
369
|
});
|