@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 CHANGED
@@ -4,9 +4,11 @@
4
4
 
5
5
  ## 主要能力
6
6
 
7
- - 账号密码、行为验证码、微信登录、OAuth、Token 刷新和退出。
8
- - 统一 HTTP、上传、下载、文件预览与 RSA/AES 密码加密。
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 backend ts-taro-app` 生成,不能手工编辑。
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 ${JSON.stringify(`${page.source}.tsx`)}
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=${JSON.stringify(page.style?.navigationStyle ?? "default")}
461
- navigationBarTitleText=${JSON.stringify(page.style?.navigationBarTitleText ?? "")}
462
- navigationBarBackgroundColor=${JSON.stringify(page.style?.navigationBarBackgroundColor ?? "#f8f8f8")}
463
- navigationBarTextStyle=${JSON.stringify(page.style?.navigationBarTextStyle ?? "black")}
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=${JSON.stringify(page.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.29",
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
- useSettingStore } from './stores'
11
- import { applyLanguageConfig, initializeLocale, registerLocaleChangeHandler, registerLocaleMessages } from './locales'
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
+ }