feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-foundation/account-adapter
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
vi.stubGlobal('IS_DEV_MODE', false);

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["dist"]
}
]
}

View File

@@ -0,0 +1,8 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
ignores: ['src/**/__tests__/**'],
});

View File

@@ -0,0 +1,43 @@
{
"name": "@coze-foundation/account-adapter",
"version": "0.0.1",
"description": " "account & login related utils & hooks & stores for open-source version"",
"license": "Apache-2.0",
"author": "duwenhan@bytedance.com",
"maintainers": [],
"main": "src/index.ts",
"scripts": {
"build": "exit 0",
"dev": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-foundation/account-base": "workspace:*",
"@coze-studio/api-schema": "workspace:*",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useLocation, useNavigate } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import { useCheckLoginBase } from '@coze-foundation/account-base';
import { signPath, signRedirectKey } from '../../utils/constants';
import { checkLoginImpl } from '../../utils';
import { useCheckLogin } from '..';
vi.mock('react-router-dom', () => ({
useLocation: vi.fn(),
useNavigate: vi.fn(),
}));
vi.mock('../../src/utils', () => ({
checkLoginImpl: vi.fn(),
}));
vi.mock('@coze-foundation/account-base', () => ({
useCheckLoginBase: vi.fn(),
}));
const mockNavigate = vi.fn();
const mockUseLocation = vi.mocked(useLocation);
const mockUseNavigate = vi.mocked(useNavigate);
describe('useCheckLogin', () => {
const mockLocation = {
pathname: '/test',
search: '?query=123',
} as any;
beforeEach(() => {
vi.clearAllMocks();
mockUseNavigate.mockReturnValue(mockNavigate);
mockUseLocation.mockReturnValue(mockLocation);
});
it('should call useCheckLoginBase with correct parameters', () => {
renderHook(() => useCheckLogin({ needLogin: true }));
expect(useCheckLoginBase).toBeCalledWith(
true,
checkLoginImpl,
expect.any(Function),
);
});
it('should navigate to default loginFallbackPath when not provided and user is not logged in', () => {
const mockUseCheckLoginBase = vi.mocked(useCheckLoginBase);
mockUseCheckLoginBase.mockImplementation(
(needLogin, checkLoginImpl, goLogin) => {
goLogin();
},
);
renderHook(() => useCheckLogin({ needLogin: true }));
expect(mockNavigate).toBeCalledWith(
`${signPath}?${signRedirectKey}=${encodeURIComponent(`${mockLocation.pathname}${mockLocation.search}`)}`,
);
});
it('should navigate to custom loginFallbackPath when provided and user is not logged in', () => {
const loginFallbackPath = '/custom-login';
const mockUseCheckLoginBase = vi.mocked(useCheckLoginBase);
mockUseCheckLoginBase.mockImplementation(
(needLogin, checkLoginImpl, goLogin) => {
goLogin();
},
);
renderHook(() => useCheckLogin({ needLogin: true, loginFallbackPath }));
expect(mockNavigate).toBeCalledWith(
`${loginFallbackPath}${mockLocation.search}`,
{ replace: true },
);
});
});

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useLocation, useNavigate } from 'react-router-dom';
import { useCheckLoginBase } from '@coze-foundation/account-base';
import { signPath, signRedirectKey } from '../utils/constants';
import { checkLoginImpl } from '../utils';
const useGoLogin = (loginFallbackPath?: string) => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
return () => {
const redirectPath = `${pathname}${search}`;
if (loginFallbackPath) {
navigate(`${loginFallbackPath}${search}`, { replace: true });
} else {
navigate(
`${signPath}?${signRedirectKey}=${encodeURIComponent(redirectPath)}`,
);
}
};
};
export const useCheckLogin = ({
needLogin,
loginFallbackPath,
}: {
needLogin?: boolean;
loginFallbackPath?: string;
}) => {
const goLogin = useGoLogin(loginFallbackPath);
useCheckLoginBase(!!needLogin, checkLoginImpl, goLogin);
};

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
getUserInfo,
getLoginStatus,
resetUserStore,
setUserInfo,
getUserLabel,
useUserInfo,
useLoginStatus,
useAlterOnLogout,
useHasError,
useUserLabel,
useUserAuthInfo,
getUserAuthInfos,
subscribeUserAuthInfos,
useSyncLocalStorageUid,
usernameRegExpValidate,
type UserInfo,
type LoginStatus,
} from '@coze-foundation/account-base';
export {
refreshUserInfo,
logout,
checkLogin,
connector2Redirect,
} from './utils';
export { useCheckLogin } from './hooks';
export { passportApi } from './passport-api';

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { passport } from '@coze-studio/api-schema';
import { passportApi } from '../index';
// 模拟 passport API
vi.mock('@coze-studio/api-schema/passport', () => ({}));
vi.mock('@coze-studio/api-schema', () => ({
passport: {
PassportAccountInfoV2: vi.fn(),
PassportWebLogoutGet: vi.fn(),
UserUpdateAvatar: vi.fn(),
PassportWebEmailPasswordResetGet: vi.fn(),
UserUpdateProfile: vi.fn(),
},
}));
vi.mock('@coze-foundation/account-base', () => ({
resetUserStore: vi.fn(),
}));
describe('passportApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('checkLogin', () => {
it('should correctly return user information', async () => {
const mockUserInfo = { name: 'test' };
vi.mocked(passport.PassportAccountInfoV2).mockResolvedValueOnce({
data: mockUserInfo,
});
const result = await passportApi.checkLogin();
expect(result).toEqual(mockUserInfo);
expect(passport.PassportAccountInfoV2).toHaveBeenCalledWith({});
});
});
describe('logout', () => {
it('should correctly call the logout API', async () => {
await passportApi.logout();
expect(passport.PassportWebLogoutGet).toHaveBeenCalledWith({
next: '/',
});
});
});
describe('uploadAvatar', () => {
it('should correctly upload avatar', async () => {
const mockFile = new File([''], 'test.png');
const mockResponse = { data: { url: 'test-url' } };
vi.mocked(passport.UserUpdateAvatar).mockResolvedValueOnce(mockResponse);
const result = await passportApi.uploadAvatar({ avatar: mockFile });
expect(result).toEqual(mockResponse.data);
expect(passport.UserUpdateAvatar).toHaveBeenCalledWith({
avatar: mockFile,
});
});
});
describe('updatePassword', () => {
it('should correctly call the password reset API', async () => {
const params = { password: 'newpass', email: 'test@example.com' };
await passportApi.updatePassword(params);
expect(passport.PassportWebEmailPasswordResetGet).toHaveBeenCalledWith({
...params,
code: '',
});
});
});
describe('updateUserProfile', () => {
it('should correctly update user profile', async () => {
const mockProfile = { nickname: 'newname' };
await passportApi.updateUserProfile(mockProfile);
expect(passport.UserUpdateProfile).toHaveBeenCalledWith(mockProfile);
});
});
});

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type UserUpdateProfileRequest } from '@coze-studio/api-schema/passport';
import { passport } from '@coze-studio/api-schema';
import { resetUserStore, type UserInfo } from '@coze-foundation/account-base';
export const passportApi = {
checkLogin: async () => {
const res = (await passport.PassportAccountInfoV2({})) as unknown as {
data: UserInfo;
};
return res.data;
},
logout: async () => {
await passport.PassportWebLogoutGet({
next: '/',
});
},
uploadAvatar: async ({ avatar }: { avatar: File }) => {
const res = await passport.UserUpdateAvatar({
avatar,
});
return res.data;
},
updatePassword: async (params: { password: string; email: string }) => {
await passport.PassportWebEmailPasswordResetGet({ ...params, code: '' });
// 更新密码后,当前登录态失效,重置 store
resetUserStore();
},
updateUserProfile: (params: UserUpdateProfileRequest) =>
passport.UserUpdateProfile(params),
};

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
refreshUserInfo,
logout,
checkLoginImpl,
checkLogin,
connector2Redirect,
} from '../index';
import {
refreshUserInfoBase,
logoutBase,
checkLoginBase,
} from '@coze-foundation/account-base';
import { passportApi } from '../../passport-api';
// Mock dependencies
vi.mock('@coze-foundation/account-base', () => ({
refreshUserInfoBase: vi.fn(),
logoutBase: vi.fn(),
checkLoginBase: vi.fn(),
}));
vi.mock('../../passport-api', () => ({
passportApi: {
checkLogin: vi.fn(),
logout: vi.fn(),
},
}));
describe('utils/index.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('refreshUserInfo', () => {
it('should call refreshUserInfoBase with passportApi.checkLogin', () => {
refreshUserInfo();
expect(refreshUserInfoBase).toHaveBeenCalledWith(passportApi.checkLogin);
});
});
describe('logout', () => {
it('should call logoutBase with passportApi.logout', () => {
logout();
expect(logoutBase).toHaveBeenCalledWith(passportApi.logout);
});
});
describe('checkLoginImpl', () => {
it('should return userInfo when passportApi.checkLogin succeeds', async () => {
const mockUserInfo = { id: '123', name: 'test' };
vi.mocked(passportApi.checkLogin).mockResolvedValue(mockUserInfo);
const result = await checkLoginImpl();
expect(result).toEqual({ userInfo: mockUserInfo });
});
it('should return undefined userInfo when passportApi.checkLogin fails', async () => {
vi.mocked(passportApi.checkLogin).mockRejectedValue(
new Error('API error'),
);
const result = await checkLoginImpl();
expect(result).toEqual({ userInfo: undefined });
});
});
describe('checkLogin', () => {
it('should call checkLoginBase with checkLoginImpl', () => {
checkLogin();
expect(checkLoginBase).toHaveBeenCalledWith(checkLoginImpl);
});
});
describe('connector2Redirect', () => {
it('should return undefined (open source version)', () => {
const result = connector2Redirect();
expect(result).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const signPath = '/sign';
export const signRedirectKey = 'redirect';

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/use-error-in-catch */
import {
refreshUserInfoBase,
logoutBase,
checkLoginBase,
type Connector2Redirect,
} from '@coze-foundation/account-base';
import { passportApi } from '../passport-api';
export const refreshUserInfo = () =>
refreshUserInfoBase(passportApi.checkLogin);
export const logout = () => logoutBase(passportApi.logout);
export const checkLoginImpl = async () => {
try {
const res = await passportApi.checkLogin();
return { userInfo: res };
} catch (e) {
return { userInfo: undefined };
}
};
export const checkLogin = () => checkLoginBase(checkLoginImpl);
// 开源版本不支持渠道授权,暂无实现
export const connector2Redirect: Connector2Redirect = () => undefined;

View File

@@ -0,0 +1,37 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"target": "ES2020",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "./src/**/*.test.ts"],
"references": [
{
"path": "../account-base/tsconfig.build.json"
},
{
"path": "../../arch/api-schema/tsconfig.build.json"
},
{
"path": "../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"target": "ES2020"
},
"include": ["**/__tests__", "vitest.config.ts"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
test: {
setupFiles: ['./__tests__/setup-vitest.ts'],
includeSource: ['./src/**/__tests__/**'],
},
});

View File

@@ -0,0 +1,16 @@
# @coze-foundation/account-base
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
vi.stubGlobal('IS_DEV_MODE', false);

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["dist"]
}
]
}

View File

@@ -0,0 +1,8 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
ignores: ['**/__tests__/*'],
});

View File

@@ -0,0 +1,47 @@
{
"name": "@coze-foundation/account-base",
"version": "0.0.1",
"description": "account & login related utils & hooks & stores",
"license": "Apache-2.0",
"author": "duwenhan@bytedance.com",
"maintainers": [],
"main": "src/index.ts",
"scripts": {
"build": "exit 0",
"dev": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/logger": "workspace:*",
"@coze-foundation/local-storage": "workspace:*",
"ahooks": "^3.7.8",
"zustand": "^4.4.7"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/foundation-sdk": "workspace:*",
"@coze-arch/idl": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0"
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
handleAPIErrorEvent,
removeAPIErrorEvent,
APIErrorEvent,
} from '@coze-arch/bot-api';
import { useCheckLoginBase } from '../factory';
import { useUserStore } from '../../store/user';
vi.mock('@coze-arch/bot-api', () => ({
handleAPIErrorEvent: vi.fn(),
removeAPIErrorEvent: vi.fn(),
APIErrorEvent: { UNAUTHORIZED: 'UNAUTHORIZED' },
}));
const mockCheckLoginImpl = vi
.fn()
.mockResolvedValue({ userInfo: null, hasError: false });
const mockGoLogin = vi.fn();
const mockReset = vi.fn();
const originalUserStore = {
...useUserStore.getState(),
reset: mockReset,
};
beforeEach(() => {
useUserStore.setState(originalUserStore);
vi.clearAllMocks();
mockGoLogin.mockReset();
});
describe('useCheckLoginBase', () => {
it('should call checkLoginBase when isSettled is false', () => {
useUserStore.setState({ isSettled: false });
renderHook(() => useCheckLoginBase(true, mockCheckLoginImpl, mockGoLogin));
expect(mockCheckLoginImpl).toHaveBeenCalledTimes(1);
});
it('should call checkLoginBase when isSettled is false and require auth is false', () => {
useUserStore.setState({ isSettled: false });
renderHook(() => useCheckLoginBase(false, mockCheckLoginImpl, mockGoLogin));
expect(mockCheckLoginImpl).toHaveBeenCalledTimes(1);
});
it('should redirect to login when needLogin is true and user is not logged in', () => {
useUserStore.setState({ isSettled: true, userInfo: null });
renderHook(() => useCheckLoginBase(true, mockCheckLoginImpl, mockGoLogin));
expect(mockGoLogin).toHaveBeenCalled();
});
it('should not redirect when user is logged in', () => {
useUserStore.setState({
isSettled: true,
userInfo: { user_id_str: '123' },
});
renderHook(() => useCheckLoginBase(true, mockCheckLoginImpl, mockGoLogin));
expect(mockGoLogin).not.toHaveBeenCalled();
});
it('should handle UNAUTHORIZED event and redirect', () => {
const { unmount } = renderHook(() =>
useCheckLoginBase(true, mockCheckLoginImpl, mockGoLogin),
);
const handleUnauthorized = (handleAPIErrorEvent as Mock).mock.calls[0][1];
act(() => handleUnauthorized());
expect(mockReset).toHaveBeenCalled();
expect(mockGoLogin).toHaveBeenCalled();
unmount();
expect(removeAPIErrorEvent).toHaveBeenCalledWith(
APIErrorEvent.UNAUTHORIZED,
handleUnauthorized,
);
});
it('should not redirect on UNAUTHORIZED if needLogin is false', () => {
renderHook(() => useCheckLoginBase(false, mockCheckLoginImpl, mockGoLogin));
const handleUnauthorized = (handleAPIErrorEvent as Mock).mock.calls[0][1];
act(() => handleUnauthorized());
expect(mockGoLogin).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,194 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useDocumentVisibility } from 'ahooks';
import { renderHook, act } from '@testing-library/react';
import { useLoginStatus, useAlterOnLogout } from '../index';
import { useUserStore } from '../../store/user';
// Mock ahooks
vi.mock('ahooks', async importOriginal => {
const original = await importOriginal();
return {
...original,
useDocumentVisibility: vi.fn(),
};
});
// Mock useUserStore
vi.mock('../../store/user', () => ({
useUserStore: vi.fn(),
}));
const UID_KEY = 'coze_current_uid';
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
length: 0,
key: (index: number) => null,
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
Object.defineProperty(window, 'addEventListener', {
value: vi.fn(),
});
Object.defineProperty(window, 'removeEventListener', {
value: vi.fn(),
});
describe('Account Hooks from index.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
// Default mock for useUserStore to return a function that can be called with a selector
(useUserStore as unknown as Mock).mockImplementation(selector =>
selector(mockUserState),
);
});
let mockUserState: any;
describe('useLoginStatus', () => {
it('should return "settling" if store is not settled', () => {
mockUserState = { isSettled: false, userInfo: null };
const { result } = renderHook(() => useLoginStatus());
expect(result.current).toBe('settling');
});
it('should return "not_login" if store is settled and no userInfo', () => {
mockUserState = { isSettled: true, userInfo: null };
const { result } = renderHook(() => useLoginStatus());
expect(result.current).toBe('not_login');
});
it('should return "not_login" if store is settled and userInfo has no user_id_str', () => {
mockUserState = { isSettled: true, userInfo: { name: 'Test' } };
const { result } = renderHook(() => useLoginStatus());
expect(result.current).toBe('not_login');
});
it('should return "logined" if store is settled and userInfo has user_id_str', () => {
mockUserState = { isSettled: true, userInfo: { user_id_str: '123' } };
const { result } = renderHook(() => useLoginStatus());
expect(result.current).toBe('logined');
});
});
describe('useAlterOnLogout', () => {
let alertMock: Mock;
beforeEach(() => {
alertMock = vi.fn();
(useDocumentVisibility as Mock).mockReturnValue('visible');
// Mock getState for the effect cleanup function
(useUserStore as any).getState = vi.fn(() => mockUserState);
});
it('should not call alert if document is visible or user is not logged in', () => {
mockUserState = { isSettled: true, userInfo: null }; // Not logged in
renderHook(() => useAlterOnLogout(alertMock));
// Simulate visibility change to hidden and back to visible (triggering cleanup and re-run)
act(() => {
(useDocumentVisibility as Mock).mockReturnValue('hidden');
});
act(() => {
(useDocumentVisibility as Mock).mockReturnValue('visible');
});
expect(alertMock).not.toHaveBeenCalled();
});
it('should call alert if user was logged in, document becomes visible, and UID in localStorage is different or null', () => {
const currentUserId = 'user123';
mockUserState = {
isSettled: true,
userInfo: { user_id_str: currentUserId },
}; // Logged in
localStorageMock.setItem(UID_KEY, currentUserId); // UID in localStorage matches
(useDocumentVisibility as Mock).mockReturnValue('hidden'); // Start hidden
const { rerender } = renderHook(() => useAlterOnLogout(alertMock));
// Simulate user logging out in another tab (localStorage UID changes)
localStorageMock.removeItem(UID_KEY);
// Simulate tab becoming visible
act(() => {
(useDocumentVisibility as Mock).mockReturnValue('visible');
});
rerender(); // Rerender to trigger useEffect with new visibility
expect(alertMock).toHaveBeenCalledTimes(1);
});
it('should call alert if user was logged in, document becomes visible, and UID in localStorage is different', () => {
const currentUserId = 'user123';
const otherTabUserId = 'user456';
mockUserState = {
isSettled: true,
userInfo: { user_id_str: currentUserId },
}; // Logged in
localStorageMock.setItem(UID_KEY, currentUserId); // UID in localStorage matches
(useDocumentVisibility as Mock).mockReturnValue('hidden'); // Start hidden
const { rerender } = renderHook(() => useAlterOnLogout(alertMock));
localStorageMock.setItem(UID_KEY, otherTabUserId); // UID changes in another tab
act(() => {
(useDocumentVisibility as Mock).mockReturnValue('visible');
});
rerender();
expect(alertMock).toHaveBeenCalledTimes(1);
});
it('should NOT call alert if user was logged in, document becomes visible, and UID in localStorage matches', () => {
const currentUserId = 'user123';
mockUserState = {
isSettled: true,
userInfo: { user_id_str: currentUserId },
}; // Logged in
localStorageMock.setItem(UID_KEY, currentUserId);
(useDocumentVisibility as Mock).mockReturnValue('hidden');
const { rerender } = renderHook(() => useAlterOnLogout(alertMock));
act(() => {
(useDocumentVisibility as Mock).mockReturnValue('visible');
});
rerender();
expect(alertMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { localStorageService } from '@coze-foundation/local-storage';
import { useSyncLocalStorageUid } from '../use-sync-local-storage-uid';
import { useLoginStatus, useUserInfo } from '../index';
// Mock hooks and services
vi.mock('../index', () => ({
useLoginStatus: vi.fn(),
useUserInfo: vi.fn(),
}));
vi.mock('@coze-foundation/local-storage', () => ({
localStorageService: {
setUserId: vi.fn(),
},
}));
describe('useSyncLocalStorageUid', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('update uid when login status changes', () => {
const mockUserInfo = { user_id_str: '123456' };
const { rerender } = renderHook(() => useSyncLocalStorageUid(), {
initialProps: {},
});
// 初始状态:未登录
(useLoginStatus as Mock).mockReturnValue('not_login');
(useUserInfo as Mock).mockReturnValue(null);
rerender();
expect(localStorageService.setUserId).toHaveBeenCalledWith();
// 切换到登录状态
(useLoginStatus as Mock).mockReturnValue('logined');
(useUserInfo as Mock).mockReturnValue(mockUserInfo);
rerender();
expect(localStorageService.setUserId).toHaveBeenCalledWith(
mockUserInfo.user_id_str,
);
});
});

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useMemoizedFn } from 'ahooks';
import {
APIErrorEvent,
handleAPIErrorEvent,
removeAPIErrorEvent,
} from '@coze-arch/bot-api';
import { checkLoginBase } from '../utils/factory';
import { type UserInfo } from '../types';
import { useUserStore } from '../store/user';
/**
* 用于页面初始化时,检查登录状态,并监听登录态失效的接口报错
* 在登录态失效时,会重定向到登录页
* @param needLogin 是否需要登录
* @param checkLogin 检查登录状态的具体实现
* @param goLogin 重定向到登录页的具体实现
*/
export const useCheckLoginBase = (
needLogin: boolean,
checkLoginImpl: () => Promise<{
userInfo?: UserInfo;
hasError?: boolean;
}>,
goLogin: () => void,
) => {
const isSettled = useUserStore(state => state.isSettled);
const memoizedGoLogin = useMemoizedFn(goLogin);
useEffect(() => {
if (!isSettled) {
checkLoginBase(checkLoginImpl);
}
}, [isSettled]);
useEffect(() => {
const isLogined = !!useUserStore.getState().userInfo?.user_id_str;
// 当前页面要求登录,登录检查结果为未登录时,重定向回登录页
if (needLogin && isSettled && !isLogined) {
memoizedGoLogin();
}
}, [needLogin, isSettled]);
useEffect(() => {
let fired = false;
const handleUnauthorized = () => {
useUserStore.getState().reset();
if (needLogin) {
if (!fired) {
fired = true;
memoizedGoLogin();
}
}
};
// ajax 请求后端接口出现未 授权/登录 时,触发该函数
handleAPIErrorEvent(APIErrorEvent.UNAUTHORIZED, handleUnauthorized);
return () => {
removeAPIErrorEvent(APIErrorEvent.UNAUTHORIZED, handleUnauthorized);
};
}, [needLogin]);
};

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useDocumentVisibility, useMemoizedFn } from 'ahooks';
import { type LoginStatus } from '../types';
import { useUserStore } from '../store/user';
/**
* @description 用于获取用户登录状态
* @returns 登录状态
*/
export const useLoginStatus = (): LoginStatus =>
useUserStore(state => {
if (state.isSettled) {
return state.userInfo?.user_id_str ? 'logined' : 'not_login';
}
return 'settling';
});
/**
* @description 用于获取用户信息
* @returns 用户信息
*/
export const useUserInfo = () => useUserStore(state => state.userInfo);
/**
* @description 当前是否为错误状态
* @returns 是否为错误状态
*/
export const useHasError = () => useUserStore(state => state.hasError);
const currentUidLSKey = 'coze_current_uid';
/**
* 用于打开多页签情况下,探测其它页签下发生的登出事件并在当前触发提示
* @param alert 触发提示的具体实现
*/
export const useAlterOnLogout = (alert: () => void) => {
const visibility = useDocumentVisibility();
const loginStatus = useLoginStatus();
const isLogined = loginStatus === 'logined';
const memoizedAlert = useMemoizedFn(() => {
alert();
});
useEffect(() => {
if (visibility === 'hidden' && isLogined) {
const lastUserId = useUserStore.getState().userInfo?.user_id_str;
// 登录态下,每次页面从后台回到前台,重新检查一次登录用户是否发生了变化
return () => {
const latestUserId = localStorage.getItem(currentUidLSKey);
if (lastUserId !== latestUserId) {
memoizedAlert();
}
};
}
}, [visibility, isLogined]);
// 在登录态变化后,更新本地缓存状态
useEffect(() => {
if (loginStatus !== 'settling') {
localStorage.setItem(
currentUidLSKey,
useUserStore.getState().userInfo?.user_id_str ?? '',
);
}
}, [loginStatus]);
};
export const useUserLabel = () => useUserStore(state => state.userLabel);
export const useUserAuthInfo = () => useUserStore(state => state.userAuthInfos);

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { localStorageService } from '@coze-foundation/local-storage';
import { useLoginStatus, useUserInfo } from './index';
export const useSyncLocalStorageUid = () => {
const userInfo = useUserInfo();
const loginStatus = useLoginStatus();
useEffect(() => {
if (loginStatus === 'logined') {
localStorageService.setUserId(userInfo?.user_id_str);
}
if (loginStatus === 'not_login') {
localStorageService.setUserId();
}
}, [loginStatus, userInfo?.user_id_str]);
};

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// types
export type { UserInfo, LoginStatus } from './types';
export type {
OAuth2RedirectConfig,
Connector2Redirect,
} from './types/passport';
// common hooks
export {
useLoginStatus,
useUserInfo,
useHasError,
useAlterOnLogout,
useUserLabel,
useUserAuthInfo,
} from './hooks';
export { useSyncLocalStorageUid } from './hooks/use-sync-local-storage-uid';
// common utils
export {
getUserInfo,
getUserLabel,
getLoginStatus,
resetUserStore,
setUserInfo,
getUserAuthInfos,
subscribeUserAuthInfos,
usernameRegExpValidate,
} from './utils';
// base hooks
export { useCheckLoginBase } from './hooks/factory';
// base utils
export {
refreshUserInfoBase,
logoutBase,
checkLoginBase,
} from './utils/factory';

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { DeveloperApi, PlaygroundApi } from '@coze-arch/bot-api';
import { useUserStore, defaultState } from '../user';
vi.mock('@coze-arch/bot-api', () => ({
DeveloperApi: {
GetUserAuthList: vi.fn(),
},
PlaygroundApi: {
MGetUserBasicInfo: vi.fn(),
},
}));
describe('useUserStore', () => {
beforeEach(() => {
useUserStore.setState(defaultState);
vi.clearAllMocks();
});
it('should have the correct default state', () => {
const state = useUserStore.getState();
expect(state.isSettled).toBe(false);
expect(state.userInfo).toBeNull();
expect(state.hasError).toBe(false);
expect(state.userAuthInfos).toEqual([]);
expect(state.userLabel).toBeNull();
});
describe('actions', () => {
it('reset should reset state to default and set isSettled to true', () => {
useUserStore.setState({
userInfo: { user_id_str: '123' } as any,
isSettled: false,
});
useUserStore.getState().reset();
const state = useUserStore.getState();
expect(state.userInfo).toBeNull();
expect(state.userAuthInfos).toEqual([]);
expect(state.userLabel).toBeNull();
expect(state.isSettled).toBe(true);
expect(state.hasError).toBe(false);
});
it('setIsSettled should update isSettled', () => {
useUserStore.getState().setIsSettled(true);
expect(useUserStore.getState().isSettled).toBe(true);
useUserStore.getState().setIsSettled(false);
expect(useUserStore.getState().isSettled).toBe(false);
});
describe('setUserInfo', () => {
it('should update userInfo', () => {
const newUserInfo = {
user_id_str: 'testUser',
name: 'Test User',
} as any;
useUserStore.getState().setUserInfo(newUserInfo);
expect(useUserStore.getState().userInfo).toEqual(newUserInfo);
});
it('should call fetchUserLabel if user_id_str changes', async () => {
const newUserInfo = {
user_id_str: 'newUser123',
name: 'New User',
} as any;
const initialUserInfo = {
user_id_str: 'oldUser456',
name: 'Old User',
} as any;
// Set an initial user
useUserStore.setState({ userInfo: initialUserInfo });
(PlaygroundApi.MGetUserBasicInfo as Mock).mockResolvedValueOnce({
id_user_info_map: {
[newUserInfo.user_id_str]: {
user_label: { label_type: 1, text: 'Test Label' },
},
},
});
useUserStore.getState().setUserInfo(newUserInfo);
expect(useUserStore.getState().userInfo).toEqual(newUserInfo);
await vi.waitFor(() => {
expect(PlaygroundApi.MGetUserBasicInfo).toHaveBeenCalledWith({
user_ids: [newUserInfo.user_id_str],
});
});
await vi.waitFor(() => {
expect(useUserStore.getState().userLabel).toEqual({
label_type: 1,
text: 'Test Label',
});
});
});
it('should not call fetchUserLabel if user_id_str is the same', () => {
const userInfo = { user_id_str: 'user123', name: 'Test User' } as any;
useUserStore.setState({ userInfo });
useUserStore.getState().setUserInfo(userInfo);
expect(PlaygroundApi.MGetUserBasicInfo).not.toHaveBeenCalled();
});
});
describe('getUserAuthInfos', () => {
it('should fetch and set userAuthInfos on success', async () => {
const mockAuthInfos = [
{ auth_type: 'email', auth_key: 'test@example.com' },
];
(DeveloperApi.GetUserAuthList as Mock).mockResolvedValueOnce({
data: mockAuthInfos,
});
await useUserStore.getState().getUserAuthInfos();
expect(DeveloperApi.GetUserAuthList).toHaveBeenCalledTimes(1);
expect(useUserStore.getState().userAuthInfos).toEqual(mockAuthInfos);
});
});
});
});

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
import {
type UserAuthInfo,
type UserLabel,
} from '@coze-arch/bot-api/developer_api';
import { DeveloperApi, PlaygroundApi } from '@coze-arch/bot-api';
import { type UserInfo } from '../types';
export interface UserStoreState {
isSettled: boolean;
hasError: boolean;
userInfo: UserInfo | null;
userAuthInfos: UserAuthInfo[];
userLabel: UserLabel | null;
}
export interface UserStoreAction {
reset: () => void;
setIsSettled: (isSettled: boolean) => void;
setUserInfo: (userInfo: UserInfo | null) => void;
getUserAuthInfos: () => Promise<void>;
}
export const defaultState: UserStoreState = {
isSettled: false,
userInfo: null,
hasError: false,
userAuthInfos: [],
userLabel: null,
};
export const useUserStore = create<UserStoreState & UserStoreAction>()(
devtools(
subscribeWithSelector((set, get) => ({
...defaultState,
reset: () => {
set({ ...defaultState, isSettled: true });
},
setIsSettled: isSettled => {
set({
isSettled,
});
},
setUserInfo: (userInfo: UserInfo | null) => {
if (
userInfo?.user_id_str &&
userInfo?.user_id_str !== get().userInfo?.user_id_str
) {
fetchUserLabel(userInfo?.user_id_str);
}
set({
userInfo,
});
},
getUserAuthInfos: async () => {
const { data = [] } = await DeveloperApi.GetUserAuthList();
set({ userAuthInfos: data });
},
})),
{
enabled: IS_DEV_MODE,
name: 'botStudio.userStore',
},
),
);
const fetchUserLabel = async (id: string) => {
const res = await PlaygroundApi.MGetUserBasicInfo({ user_ids: [id] });
const userLabel = res?.id_user_info_map?.[id]?.user_label;
useUserStore.setState({ userLabel });
};

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* 当前登录账号的用户信息
*/
export interface UserInfo {
app_id: number;
/**
* @deprecated 会因为溢出丢失精度,使用 user_id_str
*/
user_id: number;
user_id_str: string;
odin_user_type: number;
name: string;
screen_name: string;
avatar_url: string;
user_verified: boolean;
email?: string;
email_collected: boolean;
expend_attrs?: Record<string, unknown>;
phone_collected: boolean;
verified_content: string;
verified_agency: string;
is_blocked: number;
is_blocking: number;
bg_img_url: string;
gender: number;
media_id: number;
user_auth_info: string;
industry: string;
area: string;
can_be_found_by_phone: number;
mobile: string;
birthday: string;
description: string;
status: number;
new_user: number;
first_login_app: number;
session_key: string;
is_recommend_allowed: number;
recommend_hint_message: string;
followings_count: number;
followers_count: number;
visit_count_recent: number;
skip_edit_profile: number;
is_manual_set_user_info: boolean;
device_id: number;
country_code: number;
has_password: number;
share_to_repost: number;
user_decoration: string;
user_privacy_extend: number;
old_user_id: number;
old_user_id_str: string;
sec_user_id: string;
sec_old_user_id: string;
vcd_account: number;
vcd_relation: number;
can_bind_visitor_account: boolean;
is_visitor_account: boolean;
is_only_bind_ins: boolean;
user_device_record_status: number;
is_kids_mode: number;
source: string;
is_employee: boolean;
passport_enterprise_user_type: number;
need_device_create: number;
need_ttwid_migration: number;
user_auth_status: number;
user_safe_mobile_2fa: string;
safe_mobile_country_code: number;
lite_user_info_string: string;
lite_user_info_demotion: number;
app_user_info: {
user_unique_name?: string;
};
need_check_bind_status: boolean;
bui_audit_info?: {
audit_info: {
user_unique_name?: string;
avatar_url?: string;
name?: string;
[key: string]: unknown;
}; // Record<string, unknown>;
// int值。1审核中2审核通过3审核不通过
audit_status: 1 | 2 | 3;
details: Record<string, unknown>;
is_auditing: boolean;
last_update_time: number;
unpass_reason: string;
};
}
/**
* 登录状态
* - settling: 登录状态检测中,一般用于首屏,会有一定的延迟
* - not_login: 未登录
* - logined: 已登录
*/
export type LoginStatus = 'settling' | 'not_login' | 'logined';

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type OAuth2StateType = 'login' | 'delete_account' | 'oauth';
export interface OAuth2RedirectConfig {
/**
* 最终的OAuth2鉴权信息将作为路由参数跳转这个参数指定目标路由地址注意在目标路由上使用
* useAuthLoginDataRouteFromOAuth2来提取路由参数并转换成用户中台三方登陆服务authLogin的参数
* 默认值为当前路径名称即不传navigatePath参数时当前路由一定要注册useAuthLoginDataRouteFromOAuth2才有效
*/
navigatePath?: string;
/**
* OAuth2回调后拿到的鉴权信息的使用场景用于在一些路由组件中区分不符合对应场景的不能用于消费
*/
type: OAuth2StateType;
/**
* 传递给OAuth2服务器的state字段会在回调时传回用于恢复网页状态
*/
extra?: {
// @ts-expect-error -- linter-disable-autofix
origin?: string;
[x: string]: string; // 用于安全监测
// @ts-expect-error -- linter-disable-autofix
encrypt_state?: string; //加密statebind_type 为 4时使用
};
scope?: string;
optionalScope?: string;
}
export interface AuthLoginInfo {
app_id?: string;
response_type?: string;
authorize_url?: string;
scope?: string;
client_id?: string;
duration?: string;
aid?: string;
client_key?: string;
}
export type Connector2Redirect = (
oauth2Config: OAuth2RedirectConfig,
platform: string,
authInfo: AuthLoginInfo,
) => void;

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />
declare const IS_DEV_MODE: boolean;

View File

@@ -0,0 +1,126 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { setUserInfoContext } from '@coze-arch/logger';
import { refreshUserInfoBase, logoutBase, checkLoginBase } from '../factory';
import { type UserInfo } from '../../types';
import { useUserStore } from '../../store/user';
// Mock dependencies
vi.mock('@coze-arch/logger', () => ({
setUserInfoContext: vi.fn(),
}));
vi.mock('../../store/user', () => ({
useUserStore: {
getState: vi.fn(),
setState: vi.fn(),
},
}));
describe('factory.ts utility functions', () => {
let mockGetState: Mock;
let mockSetState: Mock;
let mockSetUserInfo: Mock;
let mockReset: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockSetUserInfo = vi.fn();
mockReset = vi.fn();
mockGetState = useUserStore.getState as Mock;
mockSetState = useUserStore.setState as Mock;
mockGetState.mockReturnValue({
setUserInfo: mockSetUserInfo,
reset: mockReset,
});
});
describe('refreshUserInfoBase', () => {
it('should correctly refresh user information', async () => {
const mockUserInfo = {
user_id_str: '123',
name: 'Test User',
} as UserInfo;
const mockCheckLogin = vi.fn().mockResolvedValue(mockUserInfo);
await refreshUserInfoBase(mockCheckLogin);
expect(mockSetState).toHaveBeenCalledWith({ hasError: false });
expect(mockCheckLogin).toHaveBeenCalled();
expect(mockSetUserInfo).toHaveBeenCalledWith(mockUserInfo);
});
});
describe('logoutBase', () => {
it('should correctly execute logout operation', async () => {
const mockLogout = vi.fn().mockResolvedValue(undefined);
await logoutBase(mockLogout);
expect(mockLogout).toHaveBeenCalled();
expect(mockReset).toHaveBeenCalled();
});
});
describe('checkLoginBase', () => {
const mockSetUserInfoContext = setUserInfoContext as Mock;
it('should correctly handle successful login state', async () => {
const mockUserInfo = {
user_id_str: '123',
name: 'Test User',
} as UserInfo;
const mockCheckLoginImpl = vi
.fn()
.mockResolvedValue({ userInfo: mockUserInfo });
await checkLoginBase(mockCheckLoginImpl);
expect(mockSetState).toHaveBeenCalledWith({ hasError: false });
expect(mockSetUserInfoContext).toHaveBeenCalledWith(mockUserInfo);
expect(mockSetState).toHaveBeenCalledWith({
userInfo: mockUserInfo,
isSettled: true,
});
});
it('should correctly handle login error state', async () => {
const mockCheckLoginImpl = vi.fn().mockResolvedValue({ hasError: true });
await checkLoginBase(mockCheckLoginImpl);
expect(mockSetState).toHaveBeenCalledWith({ hasError: false });
expect(mockSetState).toHaveBeenCalledWith({ hasError: true });
expect(mockSetUserInfoContext).not.toHaveBeenCalled();
});
it('should correctly handle not logged in state', async () => {
const mockCheckLoginImpl = vi.fn().mockResolvedValue({ userInfo: null });
await checkLoginBase(mockCheckLoginImpl);
expect(mockSetState).toHaveBeenCalledWith({ hasError: false });
expect(mockSetState).toHaveBeenCalledWith({
userInfo: null,
isSettled: true,
});
expect(mockSetUserInfoContext).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { I18n } from '@coze-arch/i18n';
import {
getUserInfo,
getLoginStatus,
resetUserStore,
setUserInfo,
getUserLabel,
getUserAuthInfos,
subscribeUserAuthInfos,
usernameRegExpValidate,
} from '../index';
import { useUserStore } from '../../store/user';
// Mock useUserStore
vi.mock('../../store/user', () => ({
useUserStore: {
getState: vi.fn(),
setState: vi.fn(), // Though not directly used by all utils, good to have for setUserInfo
subscribe: vi.fn(),
},
}));
// Mock I18n
vi.mock('@coze-arch/i18n', () => ({
I18n: {
t: vi.fn(key => key), // Simple mock that returns the key
},
}));
describe('Utility functions from utils/index.ts', () => {
let mockGetState: Mock;
let mockSubscribe: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockGetState = useUserStore.getState as Mock;
mockSubscribe = useUserStore.subscribe as Mock;
});
describe('getUserInfo', () => {
it('should return userInfo from userStore.getState()', () => {
const mockUser = { user_id_str: 'testUser', name: 'Test User' };
mockGetState.mockReturnValue({ userInfo: mockUser });
expect(getUserInfo()).toEqual(mockUser);
expect(mockGetState).toHaveBeenCalledTimes(1);
});
});
describe('getLoginStatus', () => {
it('should return "settling" if store is not settled', () => {
mockGetState.mockReturnValue({ isSettled: false, userInfo: null });
expect(getLoginStatus()).toBe('settling');
});
it('should return "not_login" if store is settled and no userInfo', () => {
mockGetState.mockReturnValue({ isSettled: true, userInfo: null });
expect(getLoginStatus()).toBe('not_login');
});
it('should return "not_login" if store is settled and userInfo has no user_id_str', () => {
mockGetState.mockReturnValue({
isSettled: true,
userInfo: { name: 'Test' },
});
expect(getLoginStatus()).toBe('not_login');
});
it('should return "logined" if store is settled and userInfo has user_id_str', () => {
mockGetState.mockReturnValue({
isSettled: true,
userInfo: { user_id_str: '123' },
});
expect(getLoginStatus()).toBe('logined');
});
});
describe('resetUserStore', () => {
it('should call reset on userStore.getState()', () => {
const mockReset = vi.fn();
mockGetState.mockReturnValue({ reset: mockReset });
resetUserStore();
expect(mockReset).toHaveBeenCalledTimes(1);
});
});
describe('setUserInfo', () => {
it('should call setUserInfo on userStore.getState() with the provided user info', () => {
const mockSetUserInfo = vi.fn();
mockGetState.mockReturnValue({ setUserInfo: mockSetUserInfo });
const newUser = { user_id_str: 'newUser', name: 'New User' };
setUserInfo(newUser);
expect(mockSetUserInfo).toHaveBeenCalledWith(newUser);
});
it('should call setUserInfo on userStore.getState() with null', () => {
const mockSetUserInfo = vi.fn();
mockGetState.mockReturnValue({ setUserInfo: mockSetUserInfo });
setUserInfo(null);
expect(mockSetUserInfo).toHaveBeenCalledWith(null);
});
});
describe('getUserLabel', () => {
it('should return userLabel from userStore.getState()', () => {
const mockLabel = { label_type: 1, text: 'VIP' };
mockGetState.mockReturnValue({ userLabel: mockLabel });
expect(getUserLabel()).toEqual(mockLabel);
expect(mockGetState).toHaveBeenCalledTimes(1);
});
});
describe('getUserAuthInfos', () => {
it('should call getUserAuthInfos on userStore.getState()', async () => {
const mockGetUserAuthInfos = vi.fn().mockResolvedValue([]);
mockGetState.mockReturnValue({ getUserAuthInfos: mockGetUserAuthInfos });
await getUserAuthInfos();
expect(mockGetUserAuthInfos).toHaveBeenCalledTimes(1);
});
});
describe('subscribeUserAuthInfos', () => {
it('should call userStore.subscribe with a selector for userAuthInfos and the callback', () => {
const callback = vi.fn();
const mockUserAuthInfos = [
{ auth_type: 'email', auth_key: 'test@example.com' },
];
// Mock the subscribe implementation to immediately call the listener with selected state
mockSubscribe.mockImplementation(
(selector, cb) => vi.fn(), // Return unsubscribe function
);
subscribeUserAuthInfos(callback);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
// Check that the selector passed to subscribe correctly extracts userAuthInfos
const selectorArg = mockSubscribe.mock.calls[0][0];
expect(selectorArg({ userAuthInfos: mockUserAuthInfos })).toEqual(
mockUserAuthInfos,
);
expect(mockSubscribe.mock.calls[0][1]).toBe(callback);
});
});
describe('usernameRegExpValidate', () => {
it('should return null for valid usernames', () => {
expect(usernameRegExpValidate('validUser123')).toBeNull();
expect(usernameRegExpValidate('another_valid_user')).toBeNull();
expect(usernameRegExpValidate('USER')).toBeNull();
expect(usernameRegExpValidate('1234')).toBeNull();
});
it('should return "username_invalid_letter" for usernames with invalid characters', () => {
(I18n.t as Mock).mockReturnValueOnce('username_invalid_letter');
expect(usernameRegExpValidate('invalid-char')).toBe(
'username_invalid_letter',
);
(I18n.t as Mock).mockReturnValueOnce('username_invalid_letter');
expect(usernameRegExpValidate('invalid char')).toBe(
'username_invalid_letter',
);
(I18n.t as Mock).mockReturnValueOnce('username_invalid_letter');
expect(usernameRegExpValidate('!@#$%^')).toBe('username_invalid_letter');
expect(I18n.t).toHaveBeenCalledWith('username_invalid_letter');
});
it('should return "username_too_short" for usernames shorter than minLength (4)', () => {
(I18n.t as Mock).mockReturnValueOnce('username_too_short');
expect(usernameRegExpValidate('abc')).toBe('username_too_short');
(I18n.t as Mock).mockReturnValueOnce('username_too_short');
expect(usernameRegExpValidate('us')).toBe('username_too_short');
expect(I18n.t).toHaveBeenCalledWith('username_too_short');
});
it('should return "username_invalid_letter" if invalid char before checking length', () => {
// This case tests if the invalid character check takes precedence
(I18n.t as Mock).mockReturnValueOnce('username_invalid_letter');
expect(usernameRegExpValidate('a-b')).toBe('username_invalid_letter'); // Length 3, but invalid char
expect(I18n.t).toHaveBeenCalledWith('username_invalid_letter');
});
});
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { setUserInfoContext } from '@coze-arch/logger';
import { type UserInfo } from '../types';
import { useUserStore } from '../store/user';
/**
* 主动触发刷新用户信息
* @param checkLogin 登录检查函数
*/
export const refreshUserInfoBase = async (
checkLogin: () => Promise<UserInfo>,
) => {
useUserStore.setState({
hasError: false,
});
const userInfo = await checkLogin();
useUserStore.getState().setUserInfo(userInfo);
};
export const logoutBase = async (logout: () => Promise<void>) => {
await logout();
useUserStore.getState().reset();
};
export const checkLoginBase = async (
checkLoginImpl: () => Promise<{
userInfo?: UserInfo;
hasError?: boolean;
}>,
) => {
useUserStore.setState({
hasError: false,
});
const { userInfo, hasError } = await checkLoginImpl();
if (hasError) {
useUserStore.setState({
hasError: true,
});
return;
}
if (userInfo) {
setUserInfoContext(userInfo);
}
useUserStore.setState({
userInfo,
isSettled: true,
});
};

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type UserAuthInfo } from '@coze-arch/idl/developer_api';
import { I18n } from '@coze-arch/i18n';
import { type UserInfo, type LoginStatus } from '../types';
import { useUserStore } from '../store/user';
/**
* 获取用户信息
* @returns UserInfo 用户信息
*/
export const getUserInfo = () => useUserStore.getState().userInfo;
/**
* 获取登录状态
* @returns LoginStatus 登录状态
*/
export const getLoginStatus = (): LoginStatus => {
const state = useUserStore.getState();
if (state.isSettled) {
return state.userInfo?.user_id_str ? 'logined' : 'not_login';
}
return 'settling';
};
export const resetUserStore = () => useUserStore.getState().reset();
export const setUserInfo = (userInfo: UserInfo | null) =>
useUserStore.getState().setUserInfo(userInfo);
export const getUserLabel = () => useUserStore.getState().userLabel;
export const getUserAuthInfos = () =>
useUserStore.getState().getUserAuthInfos();
export const subscribeUserAuthInfos = (
callback: (state: UserAuthInfo[], prev: UserAuthInfo[]) => void,
) => useUserStore.subscribe(state => state.userAuthInfos, callback);
const usernameRegExp = /^[0-9A-Za-z_]+$/;
const minLength = 4;
export const usernameRegExpValidate = (value: string) => {
if (!usernameRegExp.exec(value)) {
return I18n.t('username_invalid_letter');
}
if (value.length < minLength) {
return I18n.t('username_too_short');
}
return null;
};

View File

@@ -0,0 +1,49 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"target": "ES2020",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/__tests__"],
"references": [
{
"path": "../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../arch/foundation-sdk/tsconfig.build.json"
},
{
"path": "../../arch/i18n/tsconfig.build.json"
},
{
"path": "../../arch/idl/tsconfig.build.json"
},
{
"path": "../../arch/logger/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../local-storage/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler"
},
"include": ["**/__tests__", "vitest.config.ts"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
test: {
setupFiles: ['./__tests__/setup-vitest.ts'],
includeSource: ['./src/**/__tests__/**'],
},
});

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-foundation/account-ui-adapter
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,64 @@
{
"name": "@coze-foundation/account-ui-adapter",
"version": "0.0.1",
"description": "账号登录 UI 组件",
"license": "Apache-2.0",
"author": "duwenhan@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.ts",
"./login-page": "./src/pages/login-page/index.tsx"
},
"main": "src/index.ts",
"typesVersions": {
"*": {
".": [
"./src/index.ts"
],
"login-page": [
"./src/pages/login-page/index.tsx"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-foundation/account-adapter": "workspace:*",
"@coze-foundation/account-ui-base": "workspace:*",
"@coze-studio/api-schema": "workspace:*",
"@coze-studio/components": "workspace:*",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
useLogout,
RequireAuthContainer,
} from '@coze-foundation/account-ui-base';
export { LoginPage } from './pages/login-page';

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import FaviconBase from './favicon-base.png';
import FaviconAddon from './favicon-addon.png';
export const Favicon = () => (
<div className="relative flex items-center">
<img
src={FaviconBase}
className="w-[100px] h-[100px] rounded-[21px] border border-solid coz-stroke-plus"
/>
<img
src={FaviconAddon}
className="absolute left-1/2 translate-x-[34px] top-[40px] w-[51px]"
/>
</div>
);

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useState } from 'react';
import { CozeBrand } from '@coze-studio/components/coze-brand';
import { I18n } from '@coze-arch/i18n';
import { Button, Form } from '@coze-arch/coze-design';
import { SignFrame, SignPanel } from '@coze-arch/bot-semi';
import { useLoginService } from './service';
import { Favicon } from './favicon';
export const LoginPage: FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [hasError, setHasError] = useState(false);
const { login, register, loginLoading, registerLoading } = useLoginService({
email,
password,
});
const submitDisabled = !email || !password || hasError;
return (
<SignFrame brandNode={<CozeBrand isOversea={IS_OVERSEA} />}>
<SignPanel className="w-[600px] h-[640px] pt-[96px]">
<div className="flex flex-col items-center w-full h-full">
<Favicon />
<div className="text-[24px] font-medium coze-fg-plug leading-[36px] mt-[32px]">
{I18n.t('open_source_login_welcome')}
</div>
<div className="mt-[64px] w-[320px] flex flex-col items-stretch [&_.semi-input-wrapper]:overflow-hidden">
<Form
onErrorChange={errors => {
setHasError(Object.keys(errors).length > 0);
}}
>
<Form.Input
data-testid="login.input.email"
noLabel
type="email"
field="email"
rules={[
{
required: true,
message: I18n.t('open_source_login_placeholder_email'),
},
{
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: I18n.t('open_source_login_placeholder_email'),
},
]}
onChange={newVal => {
setEmail(newVal);
}}
placeholder={I18n.t('open_source_login_placeholder_email')}
/>
<Form.Input
data-testid="login.input.password"
noLabel
rules={[
{
required: true,
message: I18n.t('open_source_login_placeholder_password'),
},
]}
field="password"
type="password"
onChange={setPassword}
placeholder={I18n.t('open_source_login_placeholder_password')}
/>
</Form>
<Button
data-testid="login.button.login"
className="mt-[12px]"
disabled={submitDisabled || registerLoading}
onClick={login}
loading={loginLoading}
color="hgltplus"
>
{I18n.t('login_button_text')}
</Button>
<Button
data-testid="login.button.signup"
className="mt-[20px]"
disabled={submitDisabled || loginLoading}
onClick={register}
loading={registerLoading}
color="primary"
>
{I18n.t('register')}
</Button>
<div className="mt-[12px] flex justify-center">
<a
data-testid="login.link.terms"
href="https://spdx.org/licenses/Apache-2.0.html"
target="_blank"
className="no-underline coz-fg-hglt"
>
{I18n.t('open_source_terms_linkname')}
</a>
</div>
</div>
</div>
</SignPanel>
</SignFrame>
);
};

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { useRequest } from 'ahooks';
import { passport } from '@coze-studio/api-schema';
import {
setUserInfo,
useLoginStatus,
type UserInfo,
} from '@coze-foundation/account-adapter';
export const useLoginService = ({
email,
password,
}: {
email: string;
password: string;
}) => {
const loginService = useRequest(
async () => {
const res = (await passport.PassportWebEmailLoginPost({
email,
password,
})) as unknown as { data: UserInfo };
return res.data;
},
{
manual: true,
onSuccess: setUserInfo,
},
);
const registerService = useRequest(
async () => {
const res = (await passport.PassportWebEmailRegisterV2Post({
email,
password,
})) as unknown as { data: UserInfo };
return res.data;
},
{
manual: true,
onSuccess: setUserInfo,
},
);
const loginStatus = useLoginStatus();
const navigate = useNavigate();
useEffect(() => {
if (loginStatus === 'logined') {
navigate('/');
}
}, [loginStatus]);
return {
login: loginService.run,
register: registerService.run,
loginLoading: loginService.loading,
registerLoading: registerService.loading,
};
};

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />
declare const IS_OVERSEA: boolean;

View File

@@ -0,0 +1,51 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "../account-adapter/tsconfig.build.json"
},
{
"path": "../account-ui-base/tsconfig.build.json"
},
{
"path": "../../arch/api-schema/tsconfig.build.json"
},
{
"path": "../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../arch/i18n/tsconfig.build.json"
},
{
"path": "../../components/bot-semi/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../../studio/components/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler"
},
"include": ["__tests__", "vitest.config.ts", "stories"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
});

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-foundation/account-ui-base
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,55 @@
{
"name": "@coze-foundation/account-ui-base",
"version": "0.0.1",
"description": "账号、登录通用UI package",
"license": "Apache-2.0",
"author": "sunzhiyuan.evan@bytedance.com",
"maintainers": [],
"main": "src/index.ts",
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-http": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-utils": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/foundation-sdk": "workspace:*",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/report-events": "workspace:*",
"@coze-common/assets": "workspace:*",
"@coze-common/biz-components": "workspace:*",
"@coze-foundation/account-adapter": "workspace:*",
"@coze-studio/user-store": "workspace:*",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { Spin } from '@coze-arch/bot-semi';
export const LoadingContainer: FC = () => (
<div className="w-full h-full flex items-center justify-center">
<Spin spinning style={{ height: '100%', width: '100%' }} />
</div>
);

View File

@@ -0,0 +1,18 @@
.container {
position: relative;
width: 100%;
height: 100%;
.mask {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #F7F7FA;
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type PropsWithChildren, type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { UIButton } from '@coze-arch/bot-semi';
import {
useHasError,
checkLogin,
useLoginStatus,
} from '@coze-foundation/account-adapter';
import { LoadingContainer } from '../loading-container';
interface ErrorPageProps {
onRetry: () => void;
}
const ErrorContainer: FC<ErrorPageProps> = ({ onRetry }) => (
<div className="w-full h-full flex items-center justify-center flex-col">
{I18n.t('login_failed')}
<UIButton onClick={onRetry}>{I18n.t('Retry')}</UIButton>
</div>
);
const Mask: FC<PropsWithChildren> = ({ children }) => (
<div className="z-1 absolute bg-[#F7F7FA] w-full h-full left-0 top-0">
{children}
</div>
);
// 在需要时渲染错误状态 & loading
const LoginCheckMask: FC<{ needLogin: boolean; loginOptional: boolean }> = ({
needLogin,
loginOptional,
}) => {
const loginStatus = useLoginStatus();
const isLogined = loginStatus === 'logined';
const hasError = useHasError();
if (hasError && needLogin) {
return (
<Mask>
<ErrorContainer onRetry={checkLogin} />;
</Mask>
);
}
if (needLogin && !loginOptional && !isLogined) {
return (
<Mask>
<LoadingContainer />
</Mask>
);
}
return null;
};
export const RequireAuthContainer: FC<
PropsWithChildren<{ needLogin: boolean; loginOptional: boolean }>
> = ({ children, needLogin, loginOptional }) => (
<>
<LoginCheckMask needLogin={needLogin} loginOptional={loginOptional} />
{children}
</>
);

View File

@@ -0,0 +1,92 @@
.update-avatar {
margin-bottom: 24px;
}
.edit-profile {
:global {
.coz-icon-button-mini {
line-height: 1;
}
.coz-typography {
@apply coz-fg-primary;
}
}
}
.label-wrap {
display: flex;
gap: 16px;
align-items: center;
align-self: flex-start;
justify-content: flex-start;
width: 100%;
margin-bottom: 20px;
}
.label {
width: 76px;
margin-bottom: 0;
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
.submit {
width: 100%;
margin-top: 48px;
}
.count {
margin: 0 12px 0 8px;
font-size: 12px;
line-height: 16px;
color: #737577;
}
.upload-button {
font-size: 12px;
line-height: 16px;
a {
font-weight: 400;
color: var(--light-usage-primary-color-primary, #0077fa);
}
}
.filed-readonly {
display: flex;
align-items: center;
width: 100%;
.text {
line-height: 20px;
}
> button {
> button:last-child,
> span:last-child {
margin-left: 4px;
}
}
}
.field-edit {
display: flex;
align-items: flex-start;
width: 100%;
.field-edit-children {
flex: 1;
}
.field-btn {
align-self: end;
}
.btn {
margin-left: 16px;
}
}

View File

@@ -0,0 +1,437 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type PropsWithChildren,
type ReactNode,
useRef,
useState,
useEffect,
} from 'react';
import classNames from 'classnames';
import { useRequest } from 'ahooks';
import { userStoreService } from '@coze-studio/user-store';
import {
passportApi,
usernameRegExpValidate,
} from '@coze-foundation/account-adapter';
import { UpdateUserAvatar } from '@coze-common/biz-components';
import { REPORT_EVENTS, createReportEvent } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { refreshUserInfo } from '@coze-arch/foundation-sdk';
import { IconCozWarningCircleFillPalette } from '@coze-arch/coze-design/icons';
import { Input, Toast, Select } from '@coze-arch/coze-design';
import { Form, type Upload } from '@coze-arch/bot-semi';
import { isApiError } from '@coze-arch/bot-http';
import { DeveloperApi } from '@coze-arch/bot-api';
import { UsernameInput } from './username-input';
import { UserInfoField, type UserInfoFieldProps } from './user-info-field';
import styles from './index.module.less';
// 用户输入 username 自动检查的时间
export const CHECK_USER_NAME_DEBOUNCE_TIME = 1000;
const WrappedInputWithCount: React.FC<
Pick<UserInfoFieldProps, 'value' | 'onChange' | 'onEnterPress'>
> = ({ value, onChange, onEnterPress }) => (
<Input
value={value}
onChange={onChange}
maxLength={20}
autoFocus
onEnterPress={onEnterPress}
placeholder={I18n.t('setting_name_placeholder')}
/>
);
const WrappedUsernameInput: React.FC<
Pick<
UserInfoFieldProps,
'value' | 'onChange' | 'onEnterPress' | 'errorMessage'
>
> = ({ value, onChange, onEnterPress, errorMessage }) => (
<UsernameInput
style={{ marginBottom: 0 }}
value={value}
errorMessage={errorMessage}
onChange={onChange}
autoFocus
onEnterPress={onEnterPress}
/>
);
const WrappedPasswordInput: React.FC<
Pick<UserInfoFieldProps, 'value' | 'onChange' | 'onEnterPress'>
> = ({ value, onChange, onEnterPress }) => (
<Input
mode="password"
value={value}
onChange={onChange}
autoFocus
onEnterPress={onEnterPress}
/>
);
const getLanguageOptions = () => [
{
label: I18n.t('settings_language_zh'),
value: 'zh-CN',
},
{
label: I18n.t('settings_language_en'),
value: 'en-US',
},
];
const WrappedSelectInput: React.FC<
Pick<
UserInfoFieldProps,
'value' | 'onChange' | 'onEnterPress' | 'errorMessage'
>
> = ({ value, onChange, onEnterPress, errorMessage }) => (
<Select
optionList={getLanguageOptions()}
value={value}
onChange={val => {
onChange?.(val as string);
}}
className="w-[120px]"
/>
);
const UserInfoFieldWrap: React.FC<PropsWithChildren<{ label?: ReactNode }>> = ({
children,
label,
}) => (
<div className={styles['label-wrap']}>
<Form.Label text={label} className={styles.label} />
{children}
</div>
);
const updateProfileEvent = createReportEvent({
eventName: REPORT_EVENTS.editUserProfile,
});
const updateProfileCheckEvent = createReportEvent({
eventName: REPORT_EVENTS.updateUserProfileCheck,
});
const getUserName = (userInfo?: DataItem.UserInfo | null): string =>
userInfo?.bui_audit_info?.audit_status === 1
? (userInfo?.bui_audit_info?.audit_info.user_unique_name ??
userInfo?.app_user_info.user_unique_name ??
'')
: (userInfo?.app_user_info.user_unique_name ?? '');
// eslint-disable-next-line @coze-arch/max-line-per-function
export const UserInfoPanel = () => {
const userInfo = userStoreService.useUserInfo();
const [nickname, setNickname] = useState(userInfo?.name);
const [username, setUsername] = useState(getUserName(userInfo));
const [userNameErrorInfo, setUsernameErrorInfo] = useState('');
const [lang, setLang] = useState(
userInfo?.locale ?? navigator.language ?? 'en-US',
);
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [avatar, setAvatar] = useState(userInfo?.avatar_url ?? '');
const uploadRef = useRef<Upload>(null);
const onNicknameChange = async (name?: string) => {
if (!name) {
return;
}
try {
updateProfileEvent.start();
setLoading(true);
await passportApi.updateUserProfile({
name,
});
updateProfileEvent.success();
} catch (error) {
updateProfileEvent.error({
error: error as Error,
reason: 'update nickname failed',
});
throw error;
} finally {
setLoading(false);
}
};
const onPasswordChange = async (newPassword?: string) => {
try {
updateProfileEvent.start();
await passportApi.updatePassword({
password: newPassword ?? '',
email: userInfo?.email ?? '',
});
updateProfileEvent.success();
} catch (error) {
updateProfileEvent.error({
error: error as Error,
reason: 'update password failed',
});
throw error;
}
};
const onLanguageChange = async (newLang?: string) => {
if (!newLang) {
return;
}
try {
updateProfileEvent.start();
await passportApi.updateUserProfile({
locale: newLang,
});
localStorage.setItem('i18next', newLang === 'en-US' ? 'en' : newLang);
updateProfileEvent.success();
// 更新语言设置需要刷新页面才能生效
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error) {
updateProfileEvent.error({
error: error as Error,
reason: 'update language failed',
});
throw error;
}
};
const handleUsernameRegexpError = (value?: string) => {
if (!value) {
setUsernameErrorInfo('');
return '';
}
const message = usernameRegExpValidate(value) || '';
setUsernameErrorInfo(message);
return message;
};
const { run: validateUsername, cancel: cancelValidateUsername } = useRequest(
async (innerUsername: string) => {
await DeveloperApi.UpdateUserProfileCheck(
{
user_unique_name: innerUsername,
},
{ __disableErrorToast: true },
);
},
{
manual: true,
debounceWait: CHECK_USER_NAME_DEBOUNCE_TIME,
debounceLeading: false,
debounceTrailing: true,
onBefore: () => {
updateProfileCheckEvent.start();
setLoading(true);
},
onError: error => {
updateProfileCheckEvent.error({ error, reason: error.message });
if (isApiError(error)) {
setUsernameErrorInfo(error.msg ?? '');
}
},
onSuccess: () => {
updateProfileCheckEvent.success();
setUsernameErrorInfo('');
},
onFinally: () => {
setLoading(false);
},
},
);
const onUsernameChange = async (innerUsername?: string) => {
if (!innerUsername) {
return;
}
try {
updateProfileEvent.start();
setLoading(true);
await passportApi.updateUserProfile({
user_unique_name: innerUsername,
});
updateProfileEvent.success();
} catch (error) {
updateProfileEvent.error({
error: error as Error,
reason: 'update username failed',
});
if (isApiError(error)) {
setUsernameErrorInfo(error.msg ?? '');
}
throw error;
} finally {
setLoading(false);
}
};
const onUserInfoFieldCancel = () => {
refreshUserInfo();
setUsernameErrorInfo('');
};
useEffect(() => {
setNickname(userInfo?.name);
setUsername(getUserName(userInfo));
setAvatar(userInfo?.avatar_url ?? '');
}, [userInfo]);
// 在进入和离开时均刷新一次用户信息
useEffect(() => {
refreshUserInfo();
return () => {
refreshUserInfo();
};
}, []);
return (
<div
className={classNames(
styles['edit-profile'],
'flex flex-col w-full h-full',
)}
>
<UpdateUserAvatar
className={styles['update-avatar']}
value={avatar}
onSuccess={url => {
setAvatar(url);
Toast.success({
content: I18n.t('upload_avatar_success'),
showClose: false,
});
}}
onError={() =>
Toast.error({
content: 'upload_avatar_failed',
})
}
ref={uploadRef}
/>
<UserInfoFieldWrap label={I18n.t('user_info_username')}>
<div className="flex">
<UserInfoField
loading={loading}
className={styles['info-field']}
value={username}
onChange={v => {
setUsername(v ?? '');
const message = handleUsernameRegexpError(v);
if (message) {
cancelValidateUsername();
setLoading(false);
} else {
v && validateUsername(v);
}
}}
customContent={
!username ? (
<div
className={classNames(
'inline-flex items-center gap-[2px] shrink-0',
'text-[12px] font-[500] coz-fg-hglt-red',
)}
>
<IconCozWarningCircleFillPalette />
{I18n.t('setting_username_empty')}
</div>
) : undefined
}
errorMessage={userNameErrorInfo}
customComponent={WrappedUsernameInput}
onSave={onUsernameChange}
onCancel={() => {
setUsername(getUserName(userInfo));
onUserInfoFieldCancel();
}}
/>
</div>
</UserInfoFieldWrap>
<UserInfoFieldWrap label={I18n.t('user_info_custom_name')}>
<div className="flex">
<UserInfoField
loading={loading}
className={styles['info-field']}
value={nickname}
onChange={setNickname}
customComponent={WrappedInputWithCount}
onSave={onNicknameChange}
onCancel={onUserInfoFieldCancel}
/>
</div>
</UserInfoFieldWrap>
<UserInfoFieldWrap label={I18n.t('user_info_email')}>
<div className="flex">
<UserInfoField
readonly
className={styles['info-field']}
value={userInfo?.email || '-'}
/>
</div>
</UserInfoFieldWrap>
<UserInfoFieldWrap label={I18n.t('user_info_password')}>
<div className="flex">
<UserInfoField
className={styles['info-field']}
value={password}
customContent={'******' /*<PasswordDesc value={password} />*/}
customComponent={WrappedPasswordInput}
onChange={val => setPassword(val ?? '')}
onSave={onPasswordChange}
onCancel={onUserInfoFieldCancel}
/>
</div>
</UserInfoFieldWrap>
<UserInfoFieldWrap label={I18n.t('language')}>
<div className="flex">
<UserInfoField
className={styles['info-field']}
value={lang}
customContent={
getLanguageOptions().find(item => item.value === lang)?.label
}
customComponent={WrappedSelectInput}
onChange={langValue =>
setLang((langValue as 'zh-CN' | 'en-US') ?? 'zh-CN')
}
onSave={onLanguageChange}
/>
</div>
</UserInfoFieldWrap>
</div>
);
};

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { IconCozEyeClose, IconCozEye } from '@coze-arch/coze-design/icons';
export const PasswordDesc = ({ value }: { value: string }) => {
const [show] = useState(false);
const displayValue = show ? value : '******';
return (
<div className="flex">
<div>{displayValue}</div>
{show ? <IconCozEye /> : <IconCozEyeClose />}
</div>
);
};

View File

@@ -0,0 +1,200 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type CSSProperties,
type ComponentType,
type PropsWithChildren,
type ReactNode,
useState,
} from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import {
IconButton,
Input,
Button,
Tooltip,
Typography,
} from '@coze-arch/coze-design';
import s from './index.module.less';
interface BaseValueProps {
value?: string;
onChange?: (v?: string) => void;
onEnterPress?: () => void;
errorMessage?: string;
}
export interface UserInfoFieldProps extends BaseValueProps {
onSave?: (v?: string) => Promise<void>;
onCancel?: () => void;
loading?: boolean;
customComponent?: ComponentType<BaseValueProps>;
className?: string;
style?: CSSProperties;
readonly?: boolean;
disabled?: boolean;
disabledTip?: ReactNode;
customContent?: ReactNode;
}
const EditWrap: React.FC<
PropsWithChildren<
Pick<
UserInfoFieldProps,
| 'onSave'
| 'onCancel'
| 'loading'
| 'className'
| 'style'
| 'errorMessage'
| 'value'
>
>
> = ({
onSave,
onCancel,
loading,
children,
className,
style,
errorMessage,
value,
}) => (
<div className={classNames(s['field-edit'], className)} style={style}>
<div className={s['field-edit-children']}>{children}</div>
<Button
className={s.btn}
color="primary"
loading={loading}
onClick={() => {
onCancel?.();
}}
data-testid="bot-edit-field-cancel-button"
>
{I18n.t('Cancel')}
</Button>
<Button
disabled={Boolean(errorMessage) || !value}
className={s.btn}
loading={loading}
onClick={() => {
onSave?.();
}}
data-testid="bot-edit-field-save-button"
>
{I18n.t('setting_name_save')}
</Button>
</div>
);
export const UserInfoField: React.FC<UserInfoFieldProps> = ({
value,
onChange,
onCancel,
// eslint-disable-next-line @typescript-eslint/naming-convention
customComponent: CustomComponent,
onSave,
loading,
className,
style,
readonly,
disabled,
disabledTip,
errorMessage,
customContent,
}) => {
const [isEdit, setEdit] = useState(false);
const handleSave = async () => {
await onSave?.(value);
setEdit(false);
};
const EditButton = (
<IconButton
disabled={disabled}
icon={<IconCozEdit />}
size="mini"
color="secondary"
className="ml-[8px]"
onClick={() => {
setEdit(true);
}}
/>
);
if (!isEdit) {
return (
<div className={classNames(s['filed-readonly'], className)} style={style}>
{customContent ? (
customContent
) : (
<Typography.Text
fontSize="14px"
className="!font-medium coz-fg-primary"
ellipsis
>
{value}
</Typography.Text>
)}
{!readonly &&
(disabled && disabledTip ? (
<Tooltip content={disabledTip}>{EditButton}</Tooltip>
) : (
EditButton
))}
</div>
);
}
if (CustomComponent) {
return (
<EditWrap
value={value}
errorMessage={errorMessage}
onSave={handleSave}
loading={loading}
onCancel={() => {
setEdit(false);
onCancel?.();
}}
>
<CustomComponent
errorMessage={errorMessage}
onEnterPress={handleSave}
value={value}
onChange={onChange}
/>
</EditWrap>
);
}
return (
<EditWrap
value={value}
errorMessage={errorMessage}
onSave={handleSave}
loading={loading}
onCancel={() => {
setEdit(false);
onCancel?.();
}}
>
<Input onEnterPress={handleSave} value={value} onChange={onChange} />
</EditWrap>
);
};

View File

@@ -0,0 +1,94 @@
.input {
input {
/* stylelint-disable-next-line declaration-no-important */
padding: 6px 0 6px 16px !important;
}
:global(.semi-input-prefix) {
width: 42px;
height: 100%;
margin: 0;
padding: 0 16px;
border-radius: 8px 0 0 8px;
}
:global(.semi-input-suffix) {
>span {
padding-right: 16px;
font-size: 14px;
font-weight: 400;
line-height: 20px;
>span:first-child {
margin-right: 4px;
}
>span:last-child {
margin-left: 4px;
}
}
}
&.page {
border-color: rgba(29, 28, 35, 16%);
}
&.modal {
border-color: rgba(29, 28, 35, 8%);
}
&:hover {
border-color: var(--semi-color-focus-border);
}
&:focus-within {
border-color: var(--semi-color-focus-border);
}
&.error {
border-color: var(--semi-color-danger-light-hover);
:global(.semi-input-prefix) {
background: #ffe0d2;
}
&:hover {
// border-color: var(--semi-color-danger-light-hover) !important;
}
&:focus-within {
/* stylelint-disable-next-line declaration-no-important */
border-color: var(--semi-color-danger) !important;
}
}
}
.page {
margin-bottom: 12px;
:global(.semi-input-prefix) {
border-right: 1px solid rgba(29, 28, 35, 16%);
}
&.error {
:global(.semi-input-prefix) {
border-right-color: transparent;
}
}
}
.modal {
margin-bottom: 8px;
:global(.semi-input-prefix) {
border-right: 1px solid rgba(29, 28, 35, 8%);
}
&.error {
:global(.semi-input-prefix) {
border-right-color: transparent;
}
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Form, Input, type InputProps } from '@coze-arch/coze-design';
import s from './index.module.less';
export const USER_NAME_MAX_LEN = 20;
interface InputWithCountProps extends InputProps {
// 设置字数限制并显示字数统计
getValueLength?: (value?: InputProps['value'] | string) => number;
}
export interface UsernameInputProps
extends Omit<
InputWithCountProps,
'prefix' | 'placeholder' | 'maxLength' | 'validateStatus'
> {
scene?: 'modal' | 'page';
errorMessage?: string;
}
export const UsernameInput: React.FC<UsernameInputProps> = ({
className,
scene = 'page',
errorMessage,
...props
}) => {
const isError = Boolean(errorMessage);
return (
<>
<Input
className={classNames(
s.input,
isError && s.error,
scene === 'modal' ? s.modal : s.page,
className,
)}
validateStatus={isError ? 'error' : 'default'}
prefix="@"
placeholder={I18n.t('username_placeholder')}
maxLength={USER_NAME_MAX_LEN}
{...props}
/>
<Form.ErrorMessage error={errorMessage} />
</>
);
};

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Modal } from '@coze-arch/coze-design';
import { logout } from '@coze-foundation/account-adapter';
export interface UseLogoutReturnType {
open: () => void;
close: () => void;
node: JSX.Element;
}
export const useLogout = (): UseLogoutReturnType => {
const navigate = useNavigate();
const [visible, setVisible] = useState(false);
const node = (
<Modal
visible={visible}
title={I18n.t('log_out_desc')}
okText={I18n.t('basic_log_out')}
cancelText={I18n.t('Cancel')}
centered
onOk={async () => {
await logout();
setVisible(false);
// 跳转到根路径
navigate('/');
}}
onCancel={() => {
setVisible(false);
}}
okButtonColor="red"
/>
);
return {
node,
open: () => {
setVisible(true);
},
close: () => {
setVisible(false);
},
};
};

View File

@@ -0,0 +1,152 @@
/* stylelint-disable declaration-no-important */
@import '@coze-common/assets/style/common.less';
.profile-modal {
.profile-left {
position: relative;
min-width: 200px;
padding: 0 8px;
:global {
.semi-tabs-tab-button.semi-tabs-tab {
@apply coz-fg-plus;
padding: 8px;
font-size: 14px;
font-weight: 500;
font-style: normal;
line-height: 20px;
}
.semi-tabs-tab-button.semi-tabs-tab-active,
.semi-tabs-tab-button:hover {
@apply coz-mg-primary;
}
.semi-tabs-tab:focus {
outline: none;
}
.semi-tabs-bar-left {
width: 184px;
}
.semi-tabs-tab-button.semi-tabs-tab-disabled {
cursor: default;
background-color: transparent;
@apply coz-fg-secondary;
}
.semi-tabs-bar-button.semi-tabs-bar-left .semi-tabs-tab:not(:last-of-type) {
margin-bottom: 6px;
}
.semi-tabs-tab:last-child::before {
content: none;
}
}
}
.divider {
position: absolute;
top: 0;
bottom: 0;
left: 200px;
height: 600px;
border-right: 1px solid var(--coz-stroke-primary);
}
.text-20 {
@apply coz-fg-plus;
font-size: 20px;
font-weight: 500;
line-height: 28px;
}
.text-16 {
@apply coz-fg-plus;
font-size: 16px;
font-weight: 500;
line-height: 22px;
}
.profile-right {
overflow-y: auto;
padding: 0 8px;
.title {
position: sticky;
z-index: 1;
top:0;
width: 100%;
font-size: 20px;
font-weight: 500 !important;
line-height: 40px;
@apply coz-fg-plus bg-background-2;
}
}
.container{
width: 100%;
height: calc(100% - 86px);
}
:global {
.semi-modal .semi-modal-content {
max-height: 600px;
padding-top:0;
padding-left: 0;
.semi-modal-body {
padding-top: 0;
}
}
.semi-modal-close {
position: absolute;
z-index: 2;
right:24px;
background-color: rgba(var(--coze-bg-2), 1);
}
}
/* 当浏览器窗口的高度大于等于760px时, 高度固定 */
@media screen and (min-height: 760px) {
:global {
.semi-modal {
height: 600px;
}
}
.profile-right {
height: 574px; // = 600px - 26px(margin-top + border)
}
}
/* 当浏览器窗口的高度小于760px时 高度 100vh - 160px */
@media screen and (max-height: 759px) {
:global {
.semi-modal {
height: calc(100vh - 160px);
}
}
.profile-right {
height: max(448px, calc(100vh - 160px - 26px));
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ReactElement, useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Typography, Space } from '@coze-arch/coze-design';
import { UITabBar, Divider } from '@coze-arch/bot-semi';
import { useModal } from './use-modal';
import s from './index.module.less';
export interface TabItem {
id: string;
tabName: string;
/**
* @param close 关闭setting弹窗
* @returns ReactElement
*/
content: (close?: () => void) => ReactElement;
}
export const useAccountSettings = ({
tabs,
onClose,
}: {
tabs: Array<TabItem | 'divider'>;
onClose?: () => void;
}) => {
const realTabs = tabs.filter(item => item !== 'divider');
const [currentTab, setCurrentTab] = useState(() => realTabs[0]?.id);
const { content, tabName } =
realTabs.find(item => String(item.id) === currentTab) || {};
const tabList = tabs.map(item => {
if (item === 'divider') {
return {
tab: <Divider className="disabled pt-[1px] pb-[1px]" />,
itemKey: 'general',
disabled: true,
};
}
return {
tab: item.tabName,
itemKey: String(item.id),
};
});
const { open, close, modal } = useModal({
title: null,
centered: true,
onCancel: () => {
onClose?.();
close();
},
className: s['profile-modal'],
height: 600,
width: 1120,
linearGradientMask: true,
});
const Content = (
<Space align="start" spacing={24} className="flex">
<Space vertical align="start" spacing={16} className={s['profile-left']}>
<Typography.Text className={`${s['text-20']} pl-[8px]`}>
{I18n.t('profile_settings')}
</Typography.Text>
<UITabBar
wrapperClass={s['profile-tab']}
tabList={tabList}
activeKey={currentTab}
onChange={setCurrentTab}
tabPosition="left"
type="button"
/>
</Space>
<div className={s.divider}></div>
<Space vertical className={'w-full' + ` ${s['profile-right']}`}>
<Typography.Text className={`${s.title}`}>{tabName}</Typography.Text>
<div className={s.container}>{content?.(close)}</div>
</Space>
</Space>
);
return {
node: <>{modal(Content)}</>,
open: (tabId?: string) => {
if (tabId && realTabs.find(item => String(item.id) === tabId)) {
setCurrentTab(tabId);
}
open();
},
close: () => {
close();
},
};
};

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { Modal, type ModalProps } from '@coze-arch/coze-design';
export type UseModalParams = Omit<ModalProps, 'visible'>;
export interface UseModalReturnValue {
modal: (inner: JSX.Element) => JSX.Element;
open: () => void;
close: () => void;
}
export const useModal = (params: UseModalParams): UseModalReturnValue => {
const [visible, setVisible] = useState(false);
return {
modal: inner => (
<Modal {...params} visible={visible}>
{inner}
</Modal>
),
open: () => setVisible(true),
close: () => setVisible(false),
};
};

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useLogout } from './hooks/logout';
export { RequireAuthContainer } from './components/require-auth-container';
export { LoadingContainer } from './components/loading-container';
export { UserInfoPanel } from './components/user-info-panel';
export { useAccountSettings } from './hooks/use-account-settings';

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,66 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "../account-adapter/tsconfig.build.json"
},
{
"path": "../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../arch/bot-http/tsconfig.build.json"
},
{
"path": "../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../arch/bot-utils/tsconfig.build.json"
},
{
"path": "../../arch/foundation-sdk/tsconfig.build.json"
},
{
"path": "../../arch/i18n/tsconfig.build.json"
},
{
"path": "../../arch/report-events/tsconfig.build.json"
},
{
"path": "../../common/assets/tsconfig.build.json"
},
{
"path": "../../common/biz-components/tsconfig.build.json"
},
{
"path": "../../components/bot-semi/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../../studio/user-store/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler"
},
"include": ["__tests__", "vitest.config.ts", "stories"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
});

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-foundation/browser-upgrade-banner
低版本浏览器升级条幅
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,46 @@
{
"name": "@coze-foundation/browser-upgrade-banner",
"version": "0.0.1",
"description": "低版本浏览器升级条幅",
"license": "Apache-2.0",
"author": "liushuoyan@bytedance.com",
"maintainers": [],
"main": "src/index.tsx",
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/logger": "workspace:*",
"@types/detect-browser": "~4.0.0",
"classnames": "^2.3.2",
"detect-browser": "~5.3.0"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,81 @@
.flex-helper {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-flex; /* 新版本语法: Chrome 21+ */
display: flex; /* 新版本语法: Opera 12.1, Firefox 22+ */
display: -webkit-box; /* 老版本语法: Safari, iOS, Android browser, older WebKit browsers. */
display: -moz-box; /* 老版本语法: Firefox (buggy) */
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -ms-flexbox; /* 混合版本语法: IE 10 */
}
.flex-1-helper {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-flex: 1; /* Chrome */
/* stylelint-disable-next-line property-no-vendor-prefix */
-ms-flex: 1; /* IE 10 */
flex: 1; /* NEW, Spec - Opera 12.1, Firefox 20+ */
-webkit-box-flex: 1; /* OLD - iOS 6-, Safari 3.1-6 */
-moz-box-flex: 1; /* OLD - Firefox 19- */
}
.flex-direction-row-helper {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-flex-direction: row;
/* stylelint-disable-next-line property-no-vendor-prefix */
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-direction: normal;
-moz-box-direction: normal;
-webkit-box-orient: horizontal;
-moz-box-orient: horizontal;
}
.flex-items-center {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-align-items: center; /* Chrome 21+, Safari 6.1+, Opera 15+ */
align-items: center; /* 新语法 */
-ms-flex-align: center; /* IE 10 */
}
.flex-justify-center {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-justify-content: center; /* Chrome 21+, Safari 6.1+ */
justify-content: center; /* 新版浏览器 */
-webkit-box-pack: center; /* iOS 6-, Safari 3.1-6 */
-moz-box-pack: center; /* 早期版本的 Firefox */
-ms-flex-pack: center; /* IE 10 */
}
.banner-wrapper {
width: 100%;
min-height: 48px;
padding: 0 64px;
background: #E5B65C;
}
.banner-item {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: #FFF;
text-align: center;
}
.banner-upgrade-button {
cursor: pointer;
margin-left: 6px;
font-weight: bold;
text-decoration-line: underline;
text-underline-offset: 4px;
}
.close {
cursor: pointer;
color: #FFF;
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
useEffect,
useRef,
useState,
type FC,
type PropsWithChildren,
} from 'react';
import classNames from 'classnames';
import { reporter } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { testLowVersionBrowse } from '../../utils';
import { EventNames } from '../../constants';
import styles from './index.module.less';
type IProps = Record<string, unknown>;
interface BannerInfo {
url: string;
visible: boolean;
}
export const BrowserUpgradeWrap: FC<PropsWithChildren<IProps>> = props => {
const { children } = props;
const [bannerInfo, setBannerInfo] = useState<BannerInfo>({
url: '',
visible: false,
});
const [bannerHeight, setBannerHeight] = useState(0);
const bannerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const result = testLowVersionBrowse();
if (!result) {
return;
}
const { downloadUrl } = result;
reporter.event({
eventName: EventNames.BrowserUpgradeTipsVisible,
});
setBannerInfo({ url: downloadUrl, visible: !!downloadUrl });
}, []);
useEffect(() => {
if (!bannerRef.current) {
setBannerHeight(0);
return;
}
setBannerHeight(bannerRef.current.getBoundingClientRect().height ?? 0);
}, [bannerInfo]);
const handleClick = () => {
if (!bannerInfo.url) {
return;
}
reporter.event({
eventName: EventNames.BrowserUpgradeClick,
});
window.open(bannerInfo.url);
};
const handleBannerClose = () => {
setBannerInfo(prevState => ({ ...prevState, visible: false }));
};
return (
<>
{bannerInfo.visible ? (
<div
className={classNames(
styles['banner-wrapper'],
styles['flex-helper'],
styles['flex-direction-row-helper'],
styles['flex-items-center'],
)}
ref={bannerRef}
>
<div
className={classNames(
styles['banner-item'],
styles['flex-1-helper'],
styles['flex-items-center'],
styles['flex-justify-center'],
)}
>
<span>{I18n.t('browser_upgrade')}: </span>
<span
className={styles['banner-upgrade-button']}
onClick={handleClick}
>
{I18n.t('browser_upgrade_button')}
</span>
</div>
<div onClick={handleBannerClose}>
<IconCozCross className={styles.close} />
</div>
</div>
) : null}
<div
style={{
height: `calc(100% - ${bannerHeight}px)`,
position: 'relative',
}}
>
{children}
</div>
</>
);
};

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const enum EventNames {
BrowserUpgradeTipsVisible = 'browserUpgradeTipsVisible',
BrowserUpgradeClick = 'browserUpgradeClick',
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { BrowserUpgradeWrap } from './components/browser-upgrade-wrap';

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function compareVersion(version1: string, version2: string): number {
// 将版本号字符串分割成数字数组这里使用map(Number)确保转换为数字类型
const parts1 = version1.split('.').map(Number);
const parts2 = version2.split('.').map(Number);
// 计算出最长的版本号长度
const maxLength = Math.max(parts1.length, parts2.length);
// 逐个比较版本号中的每个部分
for (let i = 0; i < maxLength; i++) {
// 如果某个版本号在这个位置没有对应的数字则视为0
const part1 = i < parts1.length ? parts1[i] : 0;
const part2 = i < parts2.length ? parts2[i] : 0;
// 比较两个版本号的当前部分
if (part1 > part2) {
return 1;
}
if (part1 < part2) {
return -1;
}
}
// 如果所有部分都相等,则版本号相等
return 0;
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { detect, type Browser } from 'detect-browser';
import { compareVersion } from './compare-version';
type VersionConfig = {
[K in Browser]?: string;
};
type DownloadConfig = {
[K in Browser]?: string;
};
const PC_VERSION_CONFIG: VersionConfig = {
['chrome']: '87.0.0',
['edge-chromium']: '100.0.0',
['edge']: '100.0.0',
['safari']: '14.0.0',
['firefox']: '79.0.0',
['ie']: '999999.0.0',
};
// cp-disable-next-line
const CN_CHROME_URL = 'https://www.google.cn/chrome/';
// cp-disable-next-line
const INTERNATIONAL_CHROME_URL = 'https://www.google.com/chrome/';
// cp-disable-next-line
const CN_EDGE_URL = 'https://www.microsoft.com/zh-cn/edge';
// cp-disable-next-line
const INTERNATIONAL_EDGE_URL = 'https://www.microsoft.com/edge';
const CN_BROWSER_DOWNLOAD_CONFIG: DownloadConfig = {
['chrome']: CN_CHROME_URL,
['edge-chromium']: CN_EDGE_URL,
['edge']: CN_EDGE_URL,
// cp-disable-next-line
['safari']: 'https://apps.apple.com/cn/app/safari/id1146562112',
// cp-disable-next-line
['firefox']: 'https://www.mozilla.org/zh-CN/firefox/new/',
['ie']: CN_CHROME_URL,
};
const INTERNATIONAL_BROWSER_DOWNLOAD_CONFIG: DownloadConfig = {
['chrome']: INTERNATIONAL_CHROME_URL,
['edge-chromium']: INTERNATIONAL_EDGE_URL,
['edge']: INTERNATIONAL_EDGE_URL,
// cp-disable-next-line
['safari']: 'https://apps.apple.com/app/safari/id1146562112',
// cp-disable-next-line
['firefox']: 'https://www.mozilla.org/firefox/new/',
['ie']: INTERNATIONAL_CHROME_URL,
};
/**
* 目前看起来 移动端 / PC 版本一致无需区分,后期如果区分,在这里通过条件区分
*/
export const testLowVersionBrowse = () => testPCVersion();
const testPCVersion = () => {
const browserInfo = detect(navigator.userAgent);
if (!browserInfo) {
return null;
}
const { name, version } = browserInfo;
// 显示的判断,用 includes 类型推断不正确
if (name === 'bot' || name === 'react-native' || name === 'node') {
return null;
}
const configVersion = PC_VERSION_CONFIG[name];
if (!configVersion) {
return null;
}
if (compareVersion(version, configVersion) >= 0) {
return null;
}
return {
downloadUrl: IS_OVERSEA
? (INTERNATIONAL_BROWSER_DOWNLOAD_CONFIG[name] ??
INTERNATIONAL_CHROME_URL)
: (CN_BROWSER_DOWNLOAD_CONFIG[name] ?? CN_CHROME_URL),
};
};

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const isMobileFromUA = () => {
const { userAgent } = navigator;
// 检查是否为移动设备
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
userAgent,
);
};

Some files were not shown because too many files have changed in this diff Show More