@liujitcn/kratos-taro-app-core 0.0.29 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/runner.mjs +13 -6
- package/package.json +2 -1
- package/src/api/base/mfa.ts +127 -0
- package/src/bootstrap.ts +26 -2
- package/src/components/KratosTabBar.scss +43 -0
- package/src/components/KratosTabBar.tsx +6 -1
- package/src/components/MfaRecoveryCodesDialog.scss +94 -0
- package/src/components/MfaRecoveryCodesDialog.tsx +61 -0
- package/src/components/MfaSetupPanel.scss +70 -0
- package/src/components/MfaSetupPanel.tsx +51 -0
- package/src/components/PasswordVerifyDialog.scss +146 -0
- package/src/components/PasswordVerifyDialog.tsx +146 -0
- package/src/index.ts +2 -0
- package/src/locales/README.md +2 -1
- package/src/locales/en-US.json +25 -1
- package/src/locales/index.ts +10 -1
- package/src/locales/ja-JP.json +25 -1
- package/src/locales/zh-CN.json +25 -1
- package/src/locales/zh-TW.json +25 -1
- package/src/navigation.ts +51 -0
- package/src/rpc/base/v1/login.ts +33 -2
- package/src/rpc/base/v1/mfa.ts +180 -0
- package/src/rpc/base/v1/oauth.ts +25 -0
- package/src/runner.ts +22 -6
- package/src/stores/user.ts +17 -5
- package/src/utils/http.ts +3 -2
- package/src/utils/webauthn.ts +94 -0
- package/src/views/pages/login/login.scss +29 -0
- package/src/views/pages/login/login.tsx +335 -52
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 主要能力
|
|
6
6
|
|
|
7
|
-
- 账号密码、行为验证码、微信登录、OAuth、Token 刷新和退出。
|
|
8
|
-
- 统一 HTTP
|
|
9
|
-
- Zustand
|
|
7
|
+
- 账号密码、行为验证码、微信登录、OAuth、MFA(TOTP/WebAuthn/恢复码)、Token 刷新和退出。
|
|
8
|
+
- 统一 HTTP、上传、下载、文件预览、RSA/AES 密码加密和 WebAuthn 工具。
|
|
9
|
+
- Zustand 用户、配置和导航状态,H5 浏览器页签标题使用宿主 HTML 声明的应用名称。
|
|
10
|
+
- 登录阶段支持 TOTP、WebAuthn 和一次性恢复码校验。
|
|
11
|
+
- 公开 Base MFA 请求及通用密码弹窗、绑定面板和恢复码弹窗,供登录页与业务模块复用同一套绑定界面。
|
|
10
12
|
- 接口驱动的逻辑路由、访问模式、自绘 tabBar 和稳定 `viewKey`。
|
|
11
13
|
- 页面静态资源 export、动态资源公共路径解析,以及 H5 默认导航栏兼容框架。
|
|
12
14
|
- 首页、登录、协议、WebView、启动/错误状态页面。
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
- `@liujitcn/kratos-taro-app-core/runner`:Taro 构建装配入口。
|
|
21
23
|
- `api/*`、`components/*`、`rpc/*`、`styles/*`、`utils/*`、`views/*`:白名单子路径。
|
|
22
24
|
|
|
23
|
-
业务模块只能通过这些 exports 使用底座,不得相对引用 core 源码。RPC 由 `make -C
|
|
25
|
+
业务模块只能通过这些 exports 使用底座,不得相对引用 core 源码。RPC 由 `make -C frontend ts-taro-app` 生成,不能手工编辑。
|
|
24
26
|
|
|
25
27
|
## Runner 协议
|
|
26
28
|
|
package/dist/runner.mjs
CHANGED
|
@@ -447,8 +447,15 @@ function appendPage(manifest, route) {
|
|
|
447
447
|
const subRoute = rest.join("/");
|
|
448
448
|
if (!subpackage.pages.includes(subRoute)) subpackage.pages.push(subRoute);
|
|
449
449
|
}
|
|
450
|
+
function typescriptStringLiteral(value) {
|
|
451
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t")}'`;
|
|
452
|
+
}
|
|
453
|
+
function pageStyleString(style, key, fallback) {
|
|
454
|
+
const value = style?.[key];
|
|
455
|
+
return typeof value === "string" ? value : fallback;
|
|
456
|
+
}
|
|
450
457
|
function createPageWrapper(page) {
|
|
451
|
-
return `import KratosPage from ${
|
|
458
|
+
return `import KratosPage from ${typescriptStringLiteral(`${page.source}.tsx`)}
|
|
452
459
|
import { KratosPageFrame } from '@liujitcn/kratos-taro-app-core/components/KratosPageFrame'
|
|
453
460
|
import { KratosTabBar } from '@liujitcn/kratos-taro-app-core/components/KratosTabBar'
|
|
454
461
|
|
|
@@ -457,14 +464,14 @@ export default function KratosPageWrapper() {
|
|
|
457
464
|
return (
|
|
458
465
|
<>
|
|
459
466
|
<KratosPageFrame
|
|
460
|
-
navigationStyle=${
|
|
461
|
-
navigationBarTitleText=${
|
|
462
|
-
navigationBarBackgroundColor=${
|
|
463
|
-
navigationBarTextStyle=${
|
|
467
|
+
navigationStyle=${typescriptStringLiteral(pageStyleString(page.style, "navigationStyle", "default"))}
|
|
468
|
+
navigationBarTitleText=${typescriptStringLiteral(pageStyleString(page.style, "navigationBarTitleText", ""))}
|
|
469
|
+
navigationBarBackgroundColor=${typescriptStringLiteral(pageStyleString(page.style, "navigationBarBackgroundColor", "#f8f8f8"))}
|
|
470
|
+
navigationBarTextStyle=${typescriptStringLiteral(pageStyleString(page.style, "navigationBarTextStyle", "black"))}
|
|
464
471
|
>
|
|
465
472
|
<KratosPage />
|
|
466
473
|
</KratosPageFrame>
|
|
467
|
-
<KratosTabBar route=${
|
|
474
|
+
<KratosTabBar route=${typescriptStringLiteral(page.route)} />
|
|
468
475
|
</>
|
|
469
476
|
)
|
|
470
477
|
}
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git@github.com:liujitcn/kratos-admin.git"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.0.
|
|
10
|
+
"version": "0.0.31",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@tarojs/taro": "4.2.1",
|
|
43
43
|
"asmcrypto.js": "2.3.2",
|
|
44
44
|
"go-captcha-react": "1.0.5",
|
|
45
|
+
"qrcode-generator": "^2.0.4",
|
|
45
46
|
"react": "18.3.1",
|
|
46
47
|
"typescript": "5.8.3",
|
|
47
48
|
"zustand": "5.0.6"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { http } from '../../utils/http'
|
|
2
|
+
import type { LoginResponse } from '../../rpc/base/v1/login'
|
|
3
|
+
import type {
|
|
4
|
+
BeginMfaDisableRequest,
|
|
5
|
+
BeginMfaDisableResponse,
|
|
6
|
+
BeginMfaEnrollmentRequest,
|
|
7
|
+
BeginMfaEnrollmentResponse,
|
|
8
|
+
BeginMfaSetupRequest,
|
|
9
|
+
BeginMfaSetupResponse,
|
|
10
|
+
ConfirmMfaEnrollmentRequest,
|
|
11
|
+
ConfirmMfaEnrollmentResponse,
|
|
12
|
+
ConfirmMfaSetupRequest,
|
|
13
|
+
ConfirmMfaSetupResponse,
|
|
14
|
+
DisableMfaRequest,
|
|
15
|
+
GetMfaStatusRequest,
|
|
16
|
+
MfaService,
|
|
17
|
+
MfaStatusResponse,
|
|
18
|
+
RecoveryCodesResponse,
|
|
19
|
+
RegenerateMfaRecoveryCodesRequest,
|
|
20
|
+
VerifyMfaRequest,
|
|
21
|
+
} from '../../rpc/base/v1/mfa'
|
|
22
|
+
import type { Empty } from '../../rpc/google/protobuf/empty'
|
|
23
|
+
|
|
24
|
+
const MFA_URL = '/v1/base/mfa'
|
|
25
|
+
|
|
26
|
+
/** 多因素认证公共服务。 */
|
|
27
|
+
export class MfaServiceImpl implements MfaService {
|
|
28
|
+
/** 校验登录阶段多因素认证。 */
|
|
29
|
+
VerifyMfa(request: VerifyMfaRequest): Promise<LoginResponse> {
|
|
30
|
+
return http<LoginResponse>({
|
|
31
|
+
url: `${MFA_URL}/verify`,
|
|
32
|
+
method: 'POST',
|
|
33
|
+
authMode: 'none',
|
|
34
|
+
data: request,
|
|
35
|
+
header: { Authorization: 'no-auth' },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 开始登录阶段的强制多因素认证绑定。 */
|
|
40
|
+
BeginMfaEnrollment(request: BeginMfaEnrollmentRequest): Promise<BeginMfaEnrollmentResponse> {
|
|
41
|
+
return http<BeginMfaEnrollmentResponse>({
|
|
42
|
+
url: `${MFA_URL}/enrollment`,
|
|
43
|
+
method: 'POST',
|
|
44
|
+
authMode: 'none',
|
|
45
|
+
data: request,
|
|
46
|
+
header: { Authorization: 'no-auth' },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 确认登录阶段的强制多因素认证绑定。 */
|
|
51
|
+
ConfirmMfaEnrollment(
|
|
52
|
+
request: ConfirmMfaEnrollmentRequest,
|
|
53
|
+
): Promise<ConfirmMfaEnrollmentResponse> {
|
|
54
|
+
return http<ConfirmMfaEnrollmentResponse>({
|
|
55
|
+
url: `${MFA_URL}/enrollment/confirm`,
|
|
56
|
+
method: 'POST',
|
|
57
|
+
authMode: 'none',
|
|
58
|
+
data: request,
|
|
59
|
+
header: { Authorization: 'no-auth' },
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 查询当前用户多因素认证状态。 */
|
|
64
|
+
GetMfaStatus(request: GetMfaStatusRequest): Promise<MfaStatusResponse> {
|
|
65
|
+
return http<MfaStatusResponse>({
|
|
66
|
+
url: MFA_URL,
|
|
67
|
+
method: 'GET',
|
|
68
|
+
authMode: 'required',
|
|
69
|
+
data: request,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 开始绑定当前用户的多因素认证方式。 */
|
|
74
|
+
BeginMfaSetup(request: BeginMfaSetupRequest): Promise<BeginMfaSetupResponse> {
|
|
75
|
+
return http<BeginMfaSetupResponse>({
|
|
76
|
+
url: `${MFA_URL}/setup`,
|
|
77
|
+
method: 'POST',
|
|
78
|
+
authMode: 'required',
|
|
79
|
+
data: request,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 确认绑定当前用户的多因素认证方式。 */
|
|
84
|
+
ConfirmMfaSetup(request: ConfirmMfaSetupRequest): Promise<ConfirmMfaSetupResponse> {
|
|
85
|
+
return http<ConfirmMfaSetupResponse>({
|
|
86
|
+
url: `${MFA_URL}/setup/confirm`,
|
|
87
|
+
method: 'POST',
|
|
88
|
+
authMode: 'required',
|
|
89
|
+
data: request,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 开始禁用 WebAuthn 多因素认证的 Passkey 验证。 */
|
|
94
|
+
BeginMfaDisable(request: BeginMfaDisableRequest): Promise<BeginMfaDisableResponse> {
|
|
95
|
+
return http<BeginMfaDisableResponse>({
|
|
96
|
+
url: `${MFA_URL}/disable/challenge`,
|
|
97
|
+
method: 'POST',
|
|
98
|
+
authMode: 'required',
|
|
99
|
+
data: request,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 禁用当前用户的多因素认证。 */
|
|
104
|
+
DisableMfa(request: DisableMfaRequest): Promise<Empty> {
|
|
105
|
+
return http<Empty>({
|
|
106
|
+
url: `${MFA_URL}/disable`,
|
|
107
|
+
method: 'POST',
|
|
108
|
+
authMode: 'required',
|
|
109
|
+
data: request,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 重新生成当前用户的多因素认证恢复码。 */
|
|
114
|
+
RegenerateMfaRecoveryCodes(
|
|
115
|
+
request: RegenerateMfaRecoveryCodesRequest,
|
|
116
|
+
): Promise<RecoveryCodesResponse> {
|
|
117
|
+
return http<RecoveryCodesResponse>({
|
|
118
|
+
url: `${MFA_URL}/recovery-codes`,
|
|
119
|
+
method: 'POST',
|
|
120
|
+
authMode: 'required',
|
|
121
|
+
data: request,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** defMfaService 多因素认证公共服务实例。 */
|
|
127
|
+
export const defMfaService = new MfaServiceImpl()
|
package/src/bootstrap.ts
CHANGED
|
@@ -6,9 +6,16 @@ import { initializeAppNavigation } from './navigation'
|
|
|
6
6
|
import {
|
|
7
7
|
registerUserStoreExtension,
|
|
8
8
|
startUserStoreEventBridge,
|
|
9
|
+
useSettingStore,
|
|
9
10
|
useUserStore,
|
|
10
|
-
|
|
11
|
-
import {
|
|
11
|
+
} from './stores'
|
|
12
|
+
import {
|
|
13
|
+
applyLanguageConfig,
|
|
14
|
+
initializeLocale,
|
|
15
|
+
registerLocaleChangeHandler,
|
|
16
|
+
registerLocaleMessages,
|
|
17
|
+
t,
|
|
18
|
+
} from './locales'
|
|
12
19
|
import { defLanguageService } from './api/base/language'
|
|
13
20
|
|
|
14
21
|
/** Taro 应用启动参数。 */
|
|
@@ -18,6 +25,21 @@ export interface KratosTaroBootstrapOptions {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
let bootstrapped = false
|
|
28
|
+
let titleObserver: MutationObserver | undefined
|
|
29
|
+
|
|
30
|
+
/** 使用宿主声明的应用名称同步 H5 浏览器页签标题。 */
|
|
31
|
+
function syncH5DocumentTitle(): void {
|
|
32
|
+
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') return
|
|
33
|
+
const titleElement = document.querySelector('title')
|
|
34
|
+
const appTitle = titleElement?.dataset.appTitle || t('core.home.main_title')
|
|
35
|
+
const applyTitle = () => {
|
|
36
|
+
if (document.title !== appTitle) document.title = appTitle
|
|
37
|
+
}
|
|
38
|
+
applyTitle()
|
|
39
|
+
if (!titleElement || titleObserver) return
|
|
40
|
+
titleObserver = new MutationObserver(applyTitle)
|
|
41
|
+
titleObserver.observe(titleElement, { childList: true })
|
|
42
|
+
}
|
|
21
43
|
|
|
22
44
|
/** 注册模块、认证生命周期和导航运行时。 */
|
|
23
45
|
export function bootstrapKratosTaroApp(options: KratosTaroBootstrapOptions): void {
|
|
@@ -34,8 +56,10 @@ export function bootstrapKratosTaroApp(options: KratosTaroBootstrapOptions): voi
|
|
|
34
56
|
})
|
|
35
57
|
if (bootstrapped) return
|
|
36
58
|
bootstrapped = true
|
|
59
|
+
syncH5DocumentTitle()
|
|
37
60
|
registerLocaleChangeHandler(initializeAppNavigation)
|
|
38
61
|
registerLocaleChangeHandler(() => useSettingStore.getState().loadData())
|
|
62
|
+
registerLocaleChangeHandler(syncH5DocumentTitle)
|
|
39
63
|
startUserStoreEventBridge()
|
|
40
64
|
registerUserStoreExtension({
|
|
41
65
|
onLogin: initializeAppNavigation,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
.kratos-tab-bar__item {
|
|
16
|
+
position: relative;
|
|
16
17
|
display: flex;
|
|
17
18
|
flex: 1;
|
|
18
19
|
align-items: center;
|
|
@@ -30,6 +31,48 @@
|
|
|
30
31
|
margin-bottom: 4PX;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
.kratos-tab-bar__message-icon {
|
|
35
|
+
position: relative;
|
|
36
|
+
width: 22PX;
|
|
37
|
+
height: 16PX;
|
|
38
|
+
margin-bottom: 8PX;
|
|
39
|
+
border: 2PX solid #9aa4b2;
|
|
40
|
+
border-radius: 3PX;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.kratos-tab-bar__message-icon::after {
|
|
45
|
+
position: absolute;
|
|
46
|
+
top: 1PX;
|
|
47
|
+
left: 4PX;
|
|
48
|
+
width: 9PX;
|
|
49
|
+
height: 9PX;
|
|
50
|
+
border-right: 2PX solid #9aa4b2;
|
|
51
|
+
border-bottom: 2PX solid #9aa4b2;
|
|
52
|
+
content: '';
|
|
53
|
+
transform: rotate(45deg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.kratos-tab-bar__message-icon--active,
|
|
57
|
+
.kratos-tab-bar__message-icon--active::after {
|
|
58
|
+
border-color: #27ba9b;
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
.kratos-tab-bar__text--active {
|
|
34
62
|
color: #27ba9b;
|
|
35
63
|
}
|
|
64
|
+
|
|
65
|
+
.kratos-tab-bar__badge {
|
|
66
|
+
position: absolute;
|
|
67
|
+
top: 2PX;
|
|
68
|
+
right: calc(50% - 22PX);
|
|
69
|
+
min-width: 14PX;
|
|
70
|
+
height: 14PX;
|
|
71
|
+
padding: 0 3PX;
|
|
72
|
+
border-radius: 8PX;
|
|
73
|
+
background: #e5484d;
|
|
74
|
+
color: #fff;
|
|
75
|
+
font-size: 9PX;
|
|
76
|
+
line-height: 14PX;
|
|
77
|
+
text-align: center;
|
|
78
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Image, Text, View } from '@tarojs/components'
|
|
2
2
|
import { resolveModuleIcon, resolveStaticView } from '../module'
|
|
3
|
-
import { APP_MENU_ROOT_ID, navigateAppRoute, useAppNavigation } from '../navigation'
|
|
3
|
+
import { APP_MENU_ROOT_ID, navigateAppRoute, useAppMenuBadges, useAppNavigation } from '../navigation'
|
|
4
4
|
import { resolveRootMenuId } from '../navigation-tree.mjs'
|
|
5
5
|
import './KratosTabBar.scss'
|
|
6
6
|
|
|
@@ -13,6 +13,7 @@ export interface KratosTabBarProps {
|
|
|
13
13
|
export function KratosTabBar({ route }: KratosTabBarProps) {
|
|
14
14
|
const menus = useAppNavigation((state) => state.menus)
|
|
15
15
|
const tabBar = useAppNavigation((state) => state.tabBar)
|
|
16
|
+
const messageBadge = useAppMenuBadges((state) => state.badges.MESSAGE_INBOX ?? 0)
|
|
16
17
|
const routeMenu = menus.find((menu) => resolveStaticView(menu.viewKey) === route)
|
|
17
18
|
const tabMenuId = routeMenu
|
|
18
19
|
? resolveRootMenuId(menus, routeMenu.id, APP_MENU_ROOT_ID)
|
|
@@ -34,6 +35,10 @@ export function KratosTabBar({ route }: KratosTabBarProps) {
|
|
|
34
35
|
{icon ? (
|
|
35
36
|
<Image className='kratos-tab-bar__icon' src={icon} mode='aspectFit' />
|
|
36
37
|
) : null}
|
|
38
|
+
{!icon && item.viewKey === 'MESSAGE_INBOX' ? (
|
|
39
|
+
<View className={`kratos-tab-bar__message-icon${active ? ' kratos-tab-bar__message-icon--active' : ''}`} />
|
|
40
|
+
) : null}
|
|
41
|
+
{item.viewKey === 'MESSAGE_INBOX' && messageBadge ? <Text className='kratos-tab-bar__badge'>{messageBadge}</Text> : null}
|
|
37
42
|
<Text className={active ? 'kratos-tab-bar__text--active' : ''}>{item.title}</Text>
|
|
38
43
|
</View>
|
|
39
44
|
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
.mfa-recovery-dialog {
|
|
2
|
+
position: fixed;
|
|
3
|
+
z-index: 1001;
|
|
4
|
+
inset: 0;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
padding: 32px;
|
|
9
|
+
background-color: rgb(0 0 0 / 45%);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.mfa-recovery-dialog__panel {
|
|
13
|
+
width: min(100%, 640px);
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
background-color: #fff;
|
|
16
|
+
border-radius: 16px;
|
|
17
|
+
box-shadow: 0 20px 64px rgb(0 0 0 / 20%);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.mfa-recovery-dialog__header {
|
|
21
|
+
padding: 28px 32px;
|
|
22
|
+
border-bottom: 1px solid #eee;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.mfa-recovery-dialog__title {
|
|
26
|
+
color: #222;
|
|
27
|
+
font-size: 34px;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.mfa-recovery-dialog__body {
|
|
32
|
+
padding: 32px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.mfa-recovery-dialog__codes {
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
width: 100%;
|
|
38
|
+
min-height: 220px;
|
|
39
|
+
margin-bottom: 16px;
|
|
40
|
+
padding: 20px 24px;
|
|
41
|
+
color: #222;
|
|
42
|
+
font-size: 30px;
|
|
43
|
+
line-height: 1.6;
|
|
44
|
+
border: 1px solid #ddd;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
overflow-wrap: anywhere;
|
|
47
|
+
white-space: pre-wrap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.mfa-recovery-dialog__copy {
|
|
51
|
+
width: auto;
|
|
52
|
+
min-width: 140px;
|
|
53
|
+
height: 58px;
|
|
54
|
+
margin: 0;
|
|
55
|
+
padding: 0 18px;
|
|
56
|
+
color: #27ba9b;
|
|
57
|
+
font-size: 24px;
|
|
58
|
+
line-height: 58px;
|
|
59
|
+
background-color: #f0fbf8;
|
|
60
|
+
border: 0;
|
|
61
|
+
border-radius: 8px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.mfa-recovery-dialog__copy::after,
|
|
65
|
+
.mfa-recovery-dialog__confirm::after {
|
|
66
|
+
border: 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.mfa-recovery-dialog__warning {
|
|
70
|
+
display: block;
|
|
71
|
+
margin-top: 12px;
|
|
72
|
+
color: #c58b00;
|
|
73
|
+
font-size: 24px;
|
|
74
|
+
line-height: 1.5;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.mfa-recovery-dialog__footer {
|
|
78
|
+
display: flex;
|
|
79
|
+
justify-content: flex-end;
|
|
80
|
+
padding: 0 32px 32px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.mfa-recovery-dialog__confirm {
|
|
84
|
+
min-width: 160px;
|
|
85
|
+
height: 72px;
|
|
86
|
+
margin: 0;
|
|
87
|
+
padding: 0 24px;
|
|
88
|
+
color: #fff;
|
|
89
|
+
font-size: 28px;
|
|
90
|
+
line-height: 72px;
|
|
91
|
+
background-color: #27ba9b;
|
|
92
|
+
border: 0;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Button, Text, View } from '@tarojs/components'
|
|
2
|
+
import Taro from '@tarojs/taro'
|
|
3
|
+
import { useI18n } from '../locales'
|
|
4
|
+
import './MfaRecoveryCodesDialog.scss'
|
|
5
|
+
|
|
6
|
+
/** MFA 恢复码弹窗属性。 */
|
|
7
|
+
export interface MfaRecoveryCodesDialogProps {
|
|
8
|
+
/** 弹窗显示状态。 */
|
|
9
|
+
open: boolean
|
|
10
|
+
/** 本次生成的一次性恢复码。 */
|
|
11
|
+
codes: string[]
|
|
12
|
+
/** 用户确认已保存恢复码。 */
|
|
13
|
+
onConfirm: () => void | Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 统一展示、复制并确认保存 MFA 恢复码。 */
|
|
17
|
+
export default function MfaRecoveryCodesDialog({
|
|
18
|
+
open,
|
|
19
|
+
codes,
|
|
20
|
+
onConfirm,
|
|
21
|
+
}: MfaRecoveryCodesDialogProps) {
|
|
22
|
+
const { t } = useI18n()
|
|
23
|
+
if (!open) return null
|
|
24
|
+
|
|
25
|
+
const codesText = codes.join('\n')
|
|
26
|
+
|
|
27
|
+
/** 复制当前显示的恢复码。 */
|
|
28
|
+
const copyRecoveryCodes = async () => {
|
|
29
|
+
if (!codesText) return
|
|
30
|
+
await Taro.setClipboardData({ data: codesText })
|
|
31
|
+
await Taro.showToast({ icon: 'none', title: t('core.login.mfa_recovery_codes_copied') })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View className='mfa-recovery-dialog'>
|
|
36
|
+
<View className='mfa-recovery-dialog__panel' onClick={(event) => event.stopPropagation()}>
|
|
37
|
+
<View className='mfa-recovery-dialog__header'>
|
|
38
|
+
<Text className='mfa-recovery-dialog__title'>
|
|
39
|
+
{t('core.login.mfa_recovery_codes_title')}
|
|
40
|
+
</Text>
|
|
41
|
+
</View>
|
|
42
|
+
<View className='mfa-recovery-dialog__body'>
|
|
43
|
+
<View className='mfa-recovery-dialog__codes'>
|
|
44
|
+
<Text>{codesText}</Text>
|
|
45
|
+
</View>
|
|
46
|
+
<Button className='mfa-recovery-dialog__copy' onClick={() => void copyRecoveryCodes()}>
|
|
47
|
+
{t('core.login.mfa_copy_recovery_codes')}
|
|
48
|
+
</Button>
|
|
49
|
+
<Text className='mfa-recovery-dialog__warning'>
|
|
50
|
+
{t('core.login.mfa_recovery_codes_warning')}
|
|
51
|
+
</Text>
|
|
52
|
+
</View>
|
|
53
|
+
<View className='mfa-recovery-dialog__footer'>
|
|
54
|
+
<Button className='mfa-recovery-dialog__confirm' onClick={() => void onConfirm()}>
|
|
55
|
+
{t('core.login.mfa_recovery_codes_confirm')}
|
|
56
|
+
</Button>
|
|
57
|
+
</View>
|
|
58
|
+
</View>
|
|
59
|
+
</View>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
.mfa-setup-panel__qr {
|
|
2
|
+
display: block;
|
|
3
|
+
width: min(360px, 100%);
|
|
4
|
+
aspect-ratio: 1;
|
|
5
|
+
margin: 0 auto 24px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.mfa-setup-panel__uri {
|
|
9
|
+
display: flex;
|
|
10
|
+
gap: 16px;
|
|
11
|
+
align-items: flex-start;
|
|
12
|
+
margin-bottom: 20px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.mfa-setup-panel__uri-text {
|
|
16
|
+
flex: 1;
|
|
17
|
+
min-width: 0;
|
|
18
|
+
color: #666;
|
|
19
|
+
font-size: 24px;
|
|
20
|
+
line-height: 1.5;
|
|
21
|
+
overflow-wrap: anywhere;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.mfa-setup-panel__copy {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
width: 58px;
|
|
29
|
+
min-width: 58px;
|
|
30
|
+
height: 58px;
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding: 0;
|
|
33
|
+
color: #27ba9b;
|
|
34
|
+
background: #f0fbf8;
|
|
35
|
+
border: 0;
|
|
36
|
+
border-radius: 8px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.mfa-setup-panel__copy::after {
|
|
40
|
+
border: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.mfa-setup-panel__copy-icon {
|
|
44
|
+
position: relative;
|
|
45
|
+
display: block;
|
|
46
|
+
width: 18px;
|
|
47
|
+
height: 18px;
|
|
48
|
+
color: currentColor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.mfa-setup-panel__copy-back,
|
|
52
|
+
.mfa-setup-panel__copy-front {
|
|
53
|
+
position: absolute;
|
|
54
|
+
width: 12px;
|
|
55
|
+
height: 14px;
|
|
56
|
+
border: 1.5px solid currentColor;
|
|
57
|
+
border-radius: 3px;
|
|
58
|
+
box-sizing: border-box;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.mfa-setup-panel__copy-back {
|
|
62
|
+
top: 0;
|
|
63
|
+
left: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.mfa-setup-panel__copy-front {
|
|
67
|
+
right: 0;
|
|
68
|
+
bottom: 0;
|
|
69
|
+
background: #f0fbf8;
|
|
70
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Button, Image, Text, View } from '@tarojs/components'
|
|
2
|
+
import Taro from '@tarojs/taro'
|
|
3
|
+
import generateQrCode from 'qrcode-generator'
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import { useI18n } from '../locales'
|
|
6
|
+
import './MfaSetupPanel.scss'
|
|
7
|
+
|
|
8
|
+
/** MFA TOTP 绑定面板属性。 */
|
|
9
|
+
export interface MfaSetupPanelProps {
|
|
10
|
+
/** TOTP 绑定地址。 */
|
|
11
|
+
uri: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 统一展示 TOTP 二维码、绑定地址和复制操作。 */
|
|
15
|
+
export default function MfaSetupPanel({ uri }: MfaSetupPanelProps) {
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const qrDataUrl = useMemo(() => {
|
|
18
|
+
if (!uri) return ''
|
|
19
|
+
const code = generateQrCode(0, 'M')
|
|
20
|
+
code.addData(uri)
|
|
21
|
+
code.make()
|
|
22
|
+
return code.createDataURL(4, 8)
|
|
23
|
+
}, [uri])
|
|
24
|
+
|
|
25
|
+
if (!uri) return null
|
|
26
|
+
|
|
27
|
+
/** 复制 TOTP 绑定地址。 */
|
|
28
|
+
const copySetupUri = async () => {
|
|
29
|
+
await Taro.setClipboardData({ data: uri })
|
|
30
|
+
await Taro.showToast({ icon: 'none', title: t('core.login.mfa_copy_success') })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View className='mfa-setup-panel'>
|
|
35
|
+
{qrDataUrl ? <Image className='mfa-setup-panel__qr' src={qrDataUrl} mode='aspectFit' /> : null}
|
|
36
|
+
<View className='mfa-setup-panel__uri'>
|
|
37
|
+
<Text className='mfa-setup-panel__uri-text'>{uri}</Text>
|
|
38
|
+
<Button
|
|
39
|
+
className='mfa-setup-panel__copy'
|
|
40
|
+
aria-label={t('core.login.mfa_copy_uri')}
|
|
41
|
+
onClick={() => void copySetupUri()}
|
|
42
|
+
>
|
|
43
|
+
<View className='mfa-setup-panel__copy-icon' aria-hidden='true'>
|
|
44
|
+
<View className='mfa-setup-panel__copy-back' />
|
|
45
|
+
<View className='mfa-setup-panel__copy-front' />
|
|
46
|
+
</View>
|
|
47
|
+
</Button>
|
|
48
|
+
</View>
|
|
49
|
+
</View>
|
|
50
|
+
)
|
|
51
|
+
}
|