@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,140 @@
|
|
|
1
|
+
import { authEnv } from '@/envs/auth';
|
|
2
|
+
|
|
3
|
+
import type { GenericProviderDefinition } from '../types';
|
|
4
|
+
|
|
5
|
+
const WECHAT_AUTHORIZATION_URL = 'https://open.weixin.qq.com/connect/qrconnect';
|
|
6
|
+
const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
|
7
|
+
const WECHAT_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
|
|
8
|
+
|
|
9
|
+
type WeChatTokenResponse = {
|
|
10
|
+
access_token?: string;
|
|
11
|
+
errcode?: number;
|
|
12
|
+
errmsg?: string;
|
|
13
|
+
expires_in?: number;
|
|
14
|
+
openid?: string;
|
|
15
|
+
refresh_token?: string;
|
|
16
|
+
scope?: string;
|
|
17
|
+
token_type?: string;
|
|
18
|
+
unionid?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const parseWechatScopes = (scope: string | undefined) =>
|
|
22
|
+
scope ? scope.split(' ').filter(Boolean) : [];
|
|
23
|
+
|
|
24
|
+
const provider: GenericProviderDefinition<{
|
|
25
|
+
AUTH_WECHAT_ID: string;
|
|
26
|
+
AUTH_WECHAT_SECRET: string;
|
|
27
|
+
}> = {
|
|
28
|
+
build: (env) => {
|
|
29
|
+
const clientId = env.AUTH_WECHAT_ID;
|
|
30
|
+
const clientSecret = env.AUTH_WECHAT_SECRET;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
authorizationUrl: WECHAT_AUTHORIZATION_URL,
|
|
34
|
+
authorizationUrlParams: {
|
|
35
|
+
appid: clientId,
|
|
36
|
+
response_type: 'code',
|
|
37
|
+
scope: 'snsapi_login',
|
|
38
|
+
},
|
|
39
|
+
clientId,
|
|
40
|
+
clientSecret,
|
|
41
|
+
/**
|
|
42
|
+
* WeChat uses a non-standard token endpoint (GET with appid/secret/code)
|
|
43
|
+
* and returns openid/unionid alongside tokens, so we exchange the code
|
|
44
|
+
* manually instead of proxying through a custom API route.
|
|
45
|
+
*/
|
|
46
|
+
getToken: async ({ code }) => {
|
|
47
|
+
const tokenUrl = new URL(WECHAT_TOKEN_URL);
|
|
48
|
+
tokenUrl.searchParams.set('appid', clientId);
|
|
49
|
+
tokenUrl.searchParams.set('secret', clientSecret);
|
|
50
|
+
tokenUrl.searchParams.set('code', code);
|
|
51
|
+
tokenUrl.searchParams.set('grant_type', 'authorization_code');
|
|
52
|
+
|
|
53
|
+
const response = await fetch(tokenUrl, { cache: 'no-store' });
|
|
54
|
+
const data = (await response.json()) as WeChatTokenResponse;
|
|
55
|
+
|
|
56
|
+
if (!response.ok || data.errcode) {
|
|
57
|
+
throw new Error(data.errmsg ?? 'Failed to fetch WeChat OAuth token');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!data.access_token || !data.openid) {
|
|
61
|
+
throw new Error('WeChat token response is missing required fields');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
accessToken: data.access_token,
|
|
66
|
+
accessTokenExpiresAt: data.expires_in
|
|
67
|
+
? new Date(Date.now() + data.expires_in * 1000)
|
|
68
|
+
: undefined,
|
|
69
|
+
expiresIn: data.expires_in,
|
|
70
|
+
raw: data,
|
|
71
|
+
refreshToken: data.refresh_token,
|
|
72
|
+
refreshTokenExpiresAt: undefined,
|
|
73
|
+
scopes: parseWechatScopes(data.scope),
|
|
74
|
+
tokenType: data.token_type ?? 'Bearer',
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
/**
|
|
78
|
+
* Use openid/unionid returned in the token response; no custom scope encoding needed.
|
|
79
|
+
*/
|
|
80
|
+
getUserInfo: async (tokens) => {
|
|
81
|
+
const accessToken = tokens.accessToken;
|
|
82
|
+
const openId = (tokens as { raw?: WeChatTokenResponse }).raw?.openid;
|
|
83
|
+
const unionId = (tokens as { raw?: WeChatTokenResponse }).raw?.unionid;
|
|
84
|
+
|
|
85
|
+
if (!accessToken || !openId) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const url = new URL(WECHAT_USERINFO_URL);
|
|
90
|
+
url.searchParams.set('access_token', accessToken);
|
|
91
|
+
url.searchParams.set('openid', openId);
|
|
92
|
+
url.searchParams.set('lang', 'zh_CN');
|
|
93
|
+
|
|
94
|
+
const response = await fetch(url, { cache: 'no-store' });
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const profile = (await response.json()) as {
|
|
100
|
+
headimgurl?: string;
|
|
101
|
+
nickname?: string;
|
|
102
|
+
unionid?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const finalUnionId = unionId ?? profile.unionid ?? openId;
|
|
106
|
+
const syntheticEmail = `${finalUnionId}@wechat.lobehub`;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
email: syntheticEmail,
|
|
110
|
+
emailVerified: false,
|
|
111
|
+
id: finalUnionId,
|
|
112
|
+
image: profile.headimgurl,
|
|
113
|
+
name: profile.nickname ?? finalUnionId,
|
|
114
|
+
...profile,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
pkce: false,
|
|
119
|
+
|
|
120
|
+
providerId: 'wechat',
|
|
121
|
+
|
|
122
|
+
responseMode: 'query',
|
|
123
|
+
|
|
124
|
+
scopes: ['snsapi_login'],
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
checkEnvs: () => {
|
|
129
|
+
return !!(authEnv.AUTH_WECHAT_ID && authEnv.AUTH_WECHAT_SECRET)
|
|
130
|
+
? {
|
|
131
|
+
AUTH_WECHAT_ID: authEnv.AUTH_WECHAT_ID,
|
|
132
|
+
AUTH_WECHAT_SECRET: authEnv.AUTH_WECHAT_SECRET,
|
|
133
|
+
}
|
|
134
|
+
: false;
|
|
135
|
+
},
|
|
136
|
+
id: 'wechat',
|
|
137
|
+
type: 'generic',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default provider;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { authEnv } from '@/envs/auth';
|
|
2
|
+
|
|
3
|
+
import { buildOidcConfig, pickEnv } from '../helpers';
|
|
4
|
+
import type { GenericProviderDefinition } from '../types';
|
|
5
|
+
|
|
6
|
+
type ZitadelEnv = {
|
|
7
|
+
AUTH_ZITADEL_ID?: string;
|
|
8
|
+
AUTH_ZITADEL_ISSUER?: string;
|
|
9
|
+
AUTH_ZITADEL_SECRET?: string;
|
|
10
|
+
ZITADEL_CLIENT_ID?: string;
|
|
11
|
+
ZITADEL_CLIENT_SECRET?: string;
|
|
12
|
+
ZITADEL_ISSUER?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const getClientId = (env: ZitadelEnv) => {
|
|
16
|
+
return pickEnv(env.ZITADEL_CLIENT_ID, env.AUTH_ZITADEL_ID);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getClientSecret = (env: ZitadelEnv) => {
|
|
20
|
+
return pickEnv(env.ZITADEL_CLIENT_SECRET, env.AUTH_ZITADEL_SECRET);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const getIssuer = (env: ZitadelEnv) => {
|
|
24
|
+
return pickEnv(env.ZITADEL_ISSUER, env.AUTH_ZITADEL_ISSUER);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const provider: GenericProviderDefinition<ZitadelEnv> = {
|
|
28
|
+
build: (env) =>
|
|
29
|
+
buildOidcConfig({
|
|
30
|
+
clientId: getClientId(env)!,
|
|
31
|
+
clientSecret: getClientSecret(env)!,
|
|
32
|
+
issuer: getIssuer(env)!,
|
|
33
|
+
providerId: 'zitadel',
|
|
34
|
+
}),
|
|
35
|
+
checkEnvs: () => {
|
|
36
|
+
const clientId = getClientId(authEnv);
|
|
37
|
+
const clientSecret = getClientSecret(authEnv);
|
|
38
|
+
const issuer = getIssuer(authEnv);
|
|
39
|
+
return !!(clientId && clientSecret && issuer)
|
|
40
|
+
? {
|
|
41
|
+
AUTH_ZITADEL_ID: authEnv.AUTH_ZITADEL_ID,
|
|
42
|
+
AUTH_ZITADEL_ISSUER: authEnv.AUTH_ZITADEL_ISSUER,
|
|
43
|
+
AUTH_ZITADEL_SECRET: authEnv.AUTH_ZITADEL_SECRET,
|
|
44
|
+
ZITADEL_CLIENT_ID: authEnv.ZITADEL_CLIENT_ID,
|
|
45
|
+
ZITADEL_CLIENT_SECRET: authEnv.ZITADEL_CLIENT_SECRET,
|
|
46
|
+
ZITADEL_ISSUER: authEnv.ZITADEL_ISSUER,
|
|
47
|
+
}
|
|
48
|
+
: false;
|
|
49
|
+
},
|
|
50
|
+
id: 'zitadel',
|
|
51
|
+
type: 'generic',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default provider;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GenericOAuthConfig } from 'better-auth/plugins';
|
|
2
|
+
import type { SocialProviders } from 'better-auth/social-providers';
|
|
3
|
+
|
|
4
|
+
export type BuiltinProviderDefinition<
|
|
5
|
+
E extends Record<string, string | undefined>,
|
|
6
|
+
Id extends keyof SocialProviders = keyof SocialProviders,
|
|
7
|
+
> = {
|
|
8
|
+
aliases?: string[];
|
|
9
|
+
build: (env: E) => SocialProviders[Id];
|
|
10
|
+
checkEnvs: () => E | false;
|
|
11
|
+
id: Id;
|
|
12
|
+
type: 'builtin';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type GenericProviderDefinition<E extends Record<string, string | undefined>> = {
|
|
16
|
+
aliases?: string[];
|
|
17
|
+
build: (env: E) => GenericOAuthConfig;
|
|
18
|
+
checkEnvs: () => E | false;
|
|
19
|
+
id: string;
|
|
20
|
+
type: 'generic';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BetterAuthProviderDefinition =
|
|
24
|
+
| BuiltinProviderDefinition<Record<string, string | undefined>>
|
|
25
|
+
| GenericProviderDefinition<Record<string, string | undefined>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { isBuiltinProvider, normalizeProviderId } from './common';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BUILTIN_BETTER_AUTH_PROVIDERS, PROVIDER_ALIAS_MAP } from '@/libs/better-auth/constants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize provider id using configured alias map (e.g. microsoft-entra-id -> microsoft).
|
|
5
|
+
*/
|
|
6
|
+
export const normalizeProviderId = (provider: string) => {
|
|
7
|
+
return PROVIDER_ALIAS_MAP[provider] || provider;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check whether a provider is handled by Better-Auth's built-in social providers.
|
|
12
|
+
* Uses alias normalization so callers can pass either canonical ids or aliases.
|
|
13
|
+
*/
|
|
14
|
+
export const isBuiltinProvider = (provider: string) => {
|
|
15
|
+
const normalized = normalizeProviderId(provider);
|
|
16
|
+
|
|
17
|
+
return BUILTIN_BETTER_AUTH_PROVIDERS.includes(
|
|
18
|
+
normalized as (typeof BUILTIN_BETTER_AUTH_PROVIDERS)[number],
|
|
19
|
+
);
|
|
20
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseSSOProviders } from './server';
|
|
4
|
+
|
|
5
|
+
describe('parseSSOProviders', () => {
|
|
6
|
+
it('should return empty array when input is undefined', () => {
|
|
7
|
+
expect(parseSSOProviders(undefined)).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should return empty array when input is empty string', () => {
|
|
11
|
+
expect(parseSSOProviders('')).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return empty array when input contains only whitespace', () => {
|
|
15
|
+
expect(parseSSOProviders(' ')).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should parse single provider', () => {
|
|
19
|
+
expect(parseSSOProviders('google')).toEqual(['google']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should parse multiple providers separated by English comma', () => {
|
|
23
|
+
expect(parseSSOProviders('google,github,microsoft')).toEqual(['google', 'github', 'microsoft']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should parse multiple providers separated by Chinese comma', () => {
|
|
27
|
+
expect(parseSSOProviders('google,github,microsoft')).toEqual([
|
|
28
|
+
'google',
|
|
29
|
+
'github',
|
|
30
|
+
'microsoft',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should parse providers with mixed comma separators', () => {
|
|
35
|
+
expect(parseSSOProviders('google,github,microsoft')).toEqual([
|
|
36
|
+
'google',
|
|
37
|
+
'github',
|
|
38
|
+
'microsoft',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should trim whitespace from providers', () => {
|
|
43
|
+
expect(parseSSOProviders(' google , github , microsoft ')).toEqual([
|
|
44
|
+
'google',
|
|
45
|
+
'github',
|
|
46
|
+
'microsoft',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should filter out empty entries', () => {
|
|
51
|
+
expect(parseSSOProviders('google,,github,,,microsoft')).toEqual([
|
|
52
|
+
'google',
|
|
53
|
+
'github',
|
|
54
|
+
'microsoft',
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should trim leading and trailing whitespace from input', () => {
|
|
59
|
+
expect(parseSSOProviders(' google,github ')).toEqual(['google', 'github']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Better-Auth SSO providers from environment variable
|
|
3
|
+
* Supports comma-separated list (both English and Chinese commas)
|
|
4
|
+
* @param providersEnv - Raw environment variable value (e.g., "google,github")
|
|
5
|
+
* @returns Array of enabled provider names
|
|
6
|
+
*/
|
|
7
|
+
export const parseSSOProviders = (providersEnv?: string): string[] => {
|
|
8
|
+
const providers = providersEnv?.trim();
|
|
9
|
+
|
|
10
|
+
if (!providers) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return providers
|
|
15
|
+
.split(/[,,]/)
|
|
16
|
+
.map((p) => p.trim())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createContextInner } from './context';
|
|
4
|
+
|
|
5
|
+
describe('createContextInner', () => {
|
|
6
|
+
it('should create context with default values when no params provided', async () => {
|
|
7
|
+
const context = await createContextInner();
|
|
8
|
+
|
|
9
|
+
expect(context).toMatchObject({
|
|
10
|
+
authorizationHeader: undefined,
|
|
11
|
+
clerkAuth: undefined,
|
|
12
|
+
marketAccessToken: undefined,
|
|
13
|
+
nextAuth: undefined,
|
|
14
|
+
oidcAuth: undefined,
|
|
15
|
+
userAgent: undefined,
|
|
16
|
+
userId: undefined,
|
|
17
|
+
});
|
|
18
|
+
expect(context.resHeaders).toBeInstanceOf(Headers);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should create context with userId', async () => {
|
|
22
|
+
const context = await createContextInner({ userId: 'user-123' });
|
|
23
|
+
|
|
24
|
+
expect(context.userId).toBe('user-123');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should create context with authorization header', async () => {
|
|
28
|
+
const context = await createContextInner({
|
|
29
|
+
authorizationHeader: 'Bearer token-abc',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(context.authorizationHeader).toBe('Bearer token-abc');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should create context with user agent', async () => {
|
|
36
|
+
const context = await createContextInner({
|
|
37
|
+
userAgent: 'Mozilla/5.0',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(context.userAgent).toBe('Mozilla/5.0');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should create context with market access token', async () => {
|
|
44
|
+
const context = await createContextInner({
|
|
45
|
+
marketAccessToken: 'mp-token-xyz',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(context.marketAccessToken).toBe('mp-token-xyz');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should create context with OIDC auth data', async () => {
|
|
52
|
+
const oidcAuth = {
|
|
53
|
+
sub: 'oidc-user-123',
|
|
54
|
+
payload: { iss: 'https://issuer.com', aud: 'client-id' },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const context = await createContextInner({ oidcAuth });
|
|
58
|
+
|
|
59
|
+
expect(context.oidcAuth).toEqual(oidcAuth);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should create context with Clerk auth data', async () => {
|
|
63
|
+
const clerkAuth = {
|
|
64
|
+
userId: 'clerk-user-id',
|
|
65
|
+
sessionId: 'session-id',
|
|
66
|
+
getToken: async () => 'clerk-token',
|
|
67
|
+
} as any;
|
|
68
|
+
|
|
69
|
+
const context = await createContextInner({ clerkAuth });
|
|
70
|
+
|
|
71
|
+
expect(context.clerkAuth).toBe(clerkAuth);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should create context with NextAuth user data', async () => {
|
|
75
|
+
const nextAuth = {
|
|
76
|
+
id: 'next-auth-user-id',
|
|
77
|
+
name: 'Test User',
|
|
78
|
+
email: 'test@example.com',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const context = await createContextInner({ nextAuth });
|
|
82
|
+
|
|
83
|
+
expect(context.nextAuth).toEqual(nextAuth);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should create context with all parameters combined', async () => {
|
|
87
|
+
const params = {
|
|
88
|
+
authorizationHeader: 'Bearer token',
|
|
89
|
+
userId: 'user-123',
|
|
90
|
+
userAgent: 'Test Agent',
|
|
91
|
+
marketAccessToken: 'mp-token',
|
|
92
|
+
oidcAuth: {
|
|
93
|
+
sub: 'oidc-sub',
|
|
94
|
+
payload: { data: 'test' },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const context = await createContextInner(params);
|
|
99
|
+
|
|
100
|
+
expect(context).toMatchObject({
|
|
101
|
+
authorizationHeader: 'Bearer token',
|
|
102
|
+
userId: 'user-123',
|
|
103
|
+
userAgent: 'Test Agent',
|
|
104
|
+
marketAccessToken: 'mp-token',
|
|
105
|
+
oidcAuth: { sub: 'oidc-sub', payload: { data: 'test' } },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should always include response headers', async () => {
|
|
110
|
+
const context1 = await createContextInner();
|
|
111
|
+
const context2 = await createContextInner({ userId: 'test' });
|
|
112
|
+
|
|
113
|
+
expect(context1.resHeaders).toBeInstanceOf(Headers);
|
|
114
|
+
expect(context2.resHeaders).toBeInstanceOf(Headers);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -7,6 +7,7 @@ import { NextRequest } from 'next/server';
|
|
|
7
7
|
import {
|
|
8
8
|
LOBE_CHAT_AUTH_HEADER,
|
|
9
9
|
LOBE_CHAT_OIDC_AUTH_HEADER,
|
|
10
|
+
enableBetterAuth,
|
|
10
11
|
enableClerk,
|
|
11
12
|
enableNextAuth,
|
|
12
13
|
} from '@/const/auth';
|
|
@@ -163,6 +164,32 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
if (enableBetterAuth) {
|
|
168
|
+
log('Attempting Better Auth authentication');
|
|
169
|
+
try {
|
|
170
|
+
const { auth: betterAuth } = await import('@/auth');
|
|
171
|
+
|
|
172
|
+
const session = await betterAuth.api.getSession({
|
|
173
|
+
headers: request.headers,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (session && session?.user?.id) {
|
|
177
|
+
userId = session.user.id;
|
|
178
|
+
log('Better Auth authentication successful, userId: %s', userId);
|
|
179
|
+
} else {
|
|
180
|
+
log('Better Auth authentication failed, no valid session');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return createContextInner({
|
|
184
|
+
...commonContext,
|
|
185
|
+
userId,
|
|
186
|
+
});
|
|
187
|
+
} catch (e) {
|
|
188
|
+
log('Better Auth authentication error: %O', e);
|
|
189
|
+
console.error('better auth err', e);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
166
193
|
if (enableNextAuth) {
|
|
167
194
|
log('Attempting NextAuth authentication');
|
|
168
195
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TRPCError } from '@trpc/server';
|
|
2
2
|
|
|
3
|
-
import { enableClerk } from '@/const/auth';
|
|
3
|
+
import { enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
|
|
4
4
|
import { DESKTOP_USER_ID } from '@/const/desktop';
|
|
5
5
|
import { isDesktop } from '@/const/version';
|
|
6
6
|
|
|
@@ -19,7 +19,9 @@ export const userAuth = trpc.middleware(async (opts) => {
|
|
|
19
19
|
if (!ctx.userId) {
|
|
20
20
|
if (enableClerk) {
|
|
21
21
|
console.log('clerk auth:', ctx.clerkAuth);
|
|
22
|
-
} else {
|
|
22
|
+
} else if (enableBetterAuth) {
|
|
23
|
+
console.log('better auth: no session found in context');
|
|
24
|
+
} else if (enableNextAuth) {
|
|
23
25
|
console.log('next auth:', ctx.nextAuth);
|
|
24
26
|
}
|
|
25
27
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
@@ -52,6 +52,104 @@ export default {
|
|
|
52
52
|
required: '内容不得为空',
|
|
53
53
|
},
|
|
54
54
|
},
|
|
55
|
+
betterAuth: {
|
|
56
|
+
errors: {
|
|
57
|
+
emailInvalid: '请输入有效的邮箱地址',
|
|
58
|
+
emailNotRegistered: '该邮箱尚未注册',
|
|
59
|
+
emailNotVerified: '邮箱尚未验证,请先验证邮箱',
|
|
60
|
+
emailRequired: '请输入邮箱地址',
|
|
61
|
+
firstNameRequired: '请输入名字',
|
|
62
|
+
lastNameRequired: '请输入姓氏',
|
|
63
|
+
loginFailed: '登录失败,请检查邮箱和密码',
|
|
64
|
+
passwordFormat: '密码必须同时包含字母和数字',
|
|
65
|
+
passwordMaxLength: '密码最多不超过 64 个字符',
|
|
66
|
+
passwordMinLength: '密码至少需要 8 个字符',
|
|
67
|
+
passwordRequired: '请输入密码',
|
|
68
|
+
usernameRequired: '请输入用户名',
|
|
69
|
+
},
|
|
70
|
+
resetPassword: {
|
|
71
|
+
backToSignIn: '返回登录',
|
|
72
|
+
confirmPasswordPlaceholder: '确认新密码',
|
|
73
|
+
confirmPasswordRequired: '请确认新密码',
|
|
74
|
+
description: '请输入您的新密码',
|
|
75
|
+
error: '重置密码失败,请重试',
|
|
76
|
+
invalidToken: '无效或已过期的重置链接',
|
|
77
|
+
newPasswordPlaceholder: '输入新密码',
|
|
78
|
+
passwordMismatch: '两次输入的密码不一致',
|
|
79
|
+
submit: '重置密码',
|
|
80
|
+
success: '密码重置成功,请使用新密码登录',
|
|
81
|
+
title: '重置密码',
|
|
82
|
+
},
|
|
83
|
+
signin: {
|
|
84
|
+
backToEmail: '返回修改邮箱',
|
|
85
|
+
continueWithAuth0: '使用 Auth0 登录',
|
|
86
|
+
continueWithAuthelia: '使用 Authelia 登录',
|
|
87
|
+
continueWithAuthentik: '使用 Authentik 登录',
|
|
88
|
+
continueWithCasdoor: '使用 Casdoor 登录',
|
|
89
|
+
continueWithCloudflareZeroTrust: '使用 Cloudflare Zero Trust 登录',
|
|
90
|
+
continueWithCognito: '使用 AWS Cognito 登录',
|
|
91
|
+
continueWithFeishu: '使用飞书登录',
|
|
92
|
+
continueWithGithub: '使用 GitHub 登录',
|
|
93
|
+
continueWithGoogle: '使用 Google 登录',
|
|
94
|
+
continueWithKeycloak: '使用 Keycloak 登录',
|
|
95
|
+
continueWithLogto: '使用 Logto 登录',
|
|
96
|
+
continueWithMicrosoft: '使用 Microsoft 登录',
|
|
97
|
+
continueWithOIDC: '使用 OIDC 登录',
|
|
98
|
+
continueWithOkta: '使用 Okta 登录',
|
|
99
|
+
continueWithWechat: '使用微信登录',
|
|
100
|
+
continueWithZitadel: '使用 Zitadel 登录',
|
|
101
|
+
emailPlaceholder: '请输入邮箱地址',
|
|
102
|
+
emailStep: {
|
|
103
|
+
subtitle: '请输入您的邮箱地址以继续',
|
|
104
|
+
title: '登录',
|
|
105
|
+
},
|
|
106
|
+
error: '登录失败,请检查邮箱和密码',
|
|
107
|
+
forgotPassword: '忘记密码?',
|
|
108
|
+
forgotPasswordError: '发送重置密码链接失败',
|
|
109
|
+
forgotPasswordSent: '重置密码链接已发送,请检查邮箱',
|
|
110
|
+
magicLinkButton: '发送登录链接',
|
|
111
|
+
magicLinkError: '发送登录链接失败,请稍后再试',
|
|
112
|
+
magicLinkSent: '登录链接已发送,请检查邮箱',
|
|
113
|
+
nextStep: '下一步',
|
|
114
|
+
noAccount: '还没有账号?',
|
|
115
|
+
orContinueWith: '或',
|
|
116
|
+
passwordPlaceholder: '请输入密码',
|
|
117
|
+
passwordStep: {
|
|
118
|
+
subtitle: '请输入密码以继续',
|
|
119
|
+
},
|
|
120
|
+
signupLink: '立即注册',
|
|
121
|
+
socialError: '社交登录失败,请重试',
|
|
122
|
+
socialOnlyHint: '该邮箱使用社交账号注册,请使用社交账号登录',
|
|
123
|
+
submit: '登录',
|
|
124
|
+
},
|
|
125
|
+
signup: {
|
|
126
|
+
emailPlaceholder: '请输入邮箱地址',
|
|
127
|
+
error: '注册失败,请重试',
|
|
128
|
+
firstNamePlaceholder: '名字',
|
|
129
|
+
hasAccount: '已有账号?',
|
|
130
|
+
lastNamePlaceholder: '姓氏',
|
|
131
|
+
passwordPlaceholder: '请输入密码',
|
|
132
|
+
signinLink: '立即登录',
|
|
133
|
+
submit: '注册',
|
|
134
|
+
subtitle: '加入 LobeChat 社区',
|
|
135
|
+
success: '注册成功!请检查您的邮箱验证邮件',
|
|
136
|
+
title: '创建账号',
|
|
137
|
+
usernamePlaceholder: '请输入用户名',
|
|
138
|
+
},
|
|
139
|
+
verifyEmail: {
|
|
140
|
+
backToSignIn: '返回登录',
|
|
141
|
+
checkSpam: '如果没有收到邮件,请检查垃圾邮件文件夹',
|
|
142
|
+
descriptionPrefix: '我们已向',
|
|
143
|
+
descriptionSuffix: '发送了验证邮件',
|
|
144
|
+
resend: {
|
|
145
|
+
button: '重新发送验证邮件',
|
|
146
|
+
error: '发送失败,请稍后重试',
|
|
147
|
+
noEmail: '邮箱地址缺失',
|
|
148
|
+
success: '验证邮件已重新发送,请检查您的邮箱',
|
|
149
|
+
},
|
|
150
|
+
title: '验证您的邮箱',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
55
153
|
date: {
|
|
56
154
|
prevMonth: '上个月',
|
|
57
155
|
recent30Days: '最近30天',
|
|
@@ -86,17 +184,32 @@ export default {
|
|
|
86
184
|
loginOrSignup: '登录 / 注册',
|
|
87
185
|
profile: {
|
|
88
186
|
avatar: '头像',
|
|
187
|
+
cancel: '取消',
|
|
188
|
+
changePassword: '重置密码',
|
|
89
189
|
email: '电子邮件地址',
|
|
190
|
+
fullName: '全名',
|
|
191
|
+
fullNameInputHint: '请输入新的全名',
|
|
192
|
+
password: '密码',
|
|
193
|
+
resetPasswordError: '发送密码重置链接失败',
|
|
194
|
+
resetPasswordSent: '密码重置链接已发送,请检查邮箱',
|
|
195
|
+
save: '保存',
|
|
90
196
|
sso: {
|
|
197
|
+
link: {
|
|
198
|
+
button: '连接帐户',
|
|
199
|
+
success: '账户关联成功',
|
|
200
|
+
},
|
|
91
201
|
loading: '正在加载已绑定的第三方账户',
|
|
92
202
|
providers: '连接的帐户',
|
|
93
203
|
unlink: {
|
|
94
204
|
description:
|
|
95
|
-
'解绑后,您将无法使用 {{provider}}
|
|
205
|
+
'解绑后,您将无法使用 {{provider}} 账户"{{providerAccountId}}"登录。如果您需要重新绑定 {{provider}} 账户到当前账户,请确保 {{provider}} 账户的邮件地址为 {{email}} ,我们会在登陆时为你自动绑定到当前登录账户。',
|
|
96
206
|
forbidden: '您至少需要保留一个第三方账户绑定。',
|
|
97
207
|
title: '是否解绑该第三方账户 {{provider}} ?',
|
|
98
208
|
},
|
|
99
209
|
},
|
|
210
|
+
title: '个人资料详情',
|
|
211
|
+
updateAvatar: '更新头像',
|
|
212
|
+
updateFullName: '更新全名',
|
|
100
213
|
username: '用户名',
|
|
101
214
|
},
|
|
102
215
|
signout: '退出登录',
|