@liujitcn/kratos-taro-app-core 0.0.58 → 0.0.60

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/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.58",
10
+ "version": "0.0.60",
11
11
  "type": "module",
12
12
  "files": [
13
13
  "dist",
@@ -7,7 +7,7 @@ import {
7
7
  } from '../../../rpc/base/v1/file'
8
8
  import { uploadFile, uploadFileList } from '../../../utils/file'
9
9
  import { getLocaleRequestHeaders, t } from '../../../locales'
10
- import { getRequestAccessToken, requestBaseURL } from '../../../utils/http'
10
+ import { getRequestAccessToken, requestBaseURL, sourceClient } from '../../../utils/http'
11
11
 
12
12
  const FILE_URL = '/v1/base/file'
13
13
 
@@ -51,7 +51,7 @@ export class FileServiceImpl {
51
51
  header: {
52
52
  ...getLocaleRequestHeaders(),
53
53
  Authorization: token,
54
- 'source-client': 'miniapp',
54
+ 'source-client': sourceClient,
55
55
  },
56
56
  })
57
57
  if (response.statusCode !== 200) throw new Error(t('core.file.download_failed'))
@@ -1,5 +1,6 @@
1
1
  import { Button, Text, View } from '@tarojs/components'
2
2
  import Taro from '@tarojs/taro'
3
+ import { useEffect, useRef } from 'react'
3
4
  import { useI18n } from '../locales'
4
5
  import './MfaRecoveryCodesDialog.scss'
5
6
 
@@ -20,6 +21,10 @@ export default function MfaRecoveryCodesDialog({
20
21
  onConfirm,
21
22
  }: MfaRecoveryCodesDialogProps) {
22
23
  const { t } = useI18n()
24
+ const confirmingRef = useRef(false)
25
+ useEffect(() => {
26
+ if (open) confirmingRef.current = false
27
+ }, [open])
23
28
  if (!open) return null
24
29
 
25
30
  const codesText = codes.join('\n')
@@ -31,6 +36,13 @@ export default function MfaRecoveryCodesDialog({
31
36
  await Taro.showToast({ icon: 'none', title: t('core.login.mfa_recovery_codes_copied') })
32
37
  }
33
38
 
39
+ /** 一次性消费确认动作,防止重复点击重复触发绑定流程。 */
40
+ const handleConfirm = () => {
41
+ if (confirmingRef.current) return
42
+ confirmingRef.current = true
43
+ void onConfirm()
44
+ }
45
+
34
46
  return (
35
47
  <View className='mfa-recovery-dialog'>
36
48
  <View className='mfa-recovery-dialog__panel' onClick={(event) => event.stopPropagation()}>
@@ -51,7 +63,7 @@ export default function MfaRecoveryCodesDialog({
51
63
  </Text>
52
64
  </View>
53
65
  <View className='mfa-recovery-dialog__footer'>
54
- <Button className='mfa-recovery-dialog__confirm' onClick={() => void onConfirm()}>
66
+ <Button className='mfa-recovery-dialog__confirm' onClick={handleConfirm}>
55
67
  {t('core.login.mfa_recovery_codes_confirm')}
56
68
  </Button>
57
69
  </View>
@@ -123,5 +123,6 @@
123
123
  "common.value.top_level": "Top level",
124
124
  "common.resource.default": "{resource}",
125
125
  "common.action.delete_resource": "Delete {resource}",
126
- "common.action.set_field_status": "Set {field}"
126
+ "common.action.set_field_status": "Set {field}",
127
+ "common.action.load_more": "Load more"
127
128
  }
@@ -123,5 +123,6 @@
123
123
  "common.value.top_level": "トップレベル",
124
124
  "common.resource.default": "{resource}",
125
125
  "common.action.delete_resource": "{resource}を削除",
126
- "common.action.set_field_status": "{field}を設定"
126
+ "common.action.set_field_status": "{field}を設定",
127
+ "common.action.load_more": "さらに読み込む"
127
128
  }
@@ -123,5 +123,6 @@
123
123
  "common.value.top_level": "顶级节点",
124
124
  "common.resource.default": "{resource}",
125
125
  "common.action.delete_resource": "删除{resource}",
126
- "common.action.set_field_status": "设置{field}"
126
+ "common.action.set_field_status": "设置{field}",
127
+ "common.action.load_more": "加载更多"
127
128
  }
@@ -123,5 +123,6 @@
123
123
  "common.value.top_level": "頂級節點",
124
124
  "common.resource.default": "{resource}",
125
125
  "common.action.delete_resource": "刪除{resource}",
126
- "common.action.set_field_status": "設定{field}"
126
+ "common.action.set_field_status": "設定{field}",
127
+ "common.action.load_more": "載入更多"
127
128
  }
@@ -3,7 +3,7 @@ import { create } from 'zustand'
3
3
  import { defLoginService } from '../api/base/v1/login'
4
4
  import { defOauthService } from '../api/base/v1/oauth'
5
5
  import { defAuthService } from '../api/system/app/v1/auth'
6
- import type { LoginRequest, LoginResponse } from '../rpc/base/v1/login'
6
+ import { LoginStatus, type LoginRequest, type LoginResponse } from '../rpc/base/v1/login'
7
7
  import type { VerifyMfaRequest } from '../rpc/base/v1/mfa'
8
8
  import { defMfaService } from '../api/base/v1/mfa'
9
9
  import type {
@@ -80,6 +80,15 @@ function persistUserInfo(userInfo?: UserProfileForm): void {
80
80
  else Taro.removeStorageSync(USER_STORAGE_KEY)
81
81
  }
82
82
 
83
+ /** 仅当登录阶段已签发非空令牌时,才允许写入本地登录态。 */
84
+ function shouldApplyLoginToken(data: LoginResponse | CreateOauthSessionResponse): boolean {
85
+ return (
86
+ (data.status === LoginStatus.LOGIN_STATUS_AUTHENTICATED ||
87
+ data.status === LoginStatus.LOGIN_STATUS_PASSWORD_CHANGE_REQUIRED) &&
88
+ Boolean(data.access_token && data.refresh_token)
89
+ )
90
+ }
91
+
83
92
  /** 用户 Zustand Store。 */
84
93
  export const useUserStore = create<UserStoreState>((set, get) => ({
85
94
  userInfo: undefined,
@@ -105,22 +114,22 @@ export const useUserStore = create<UserStoreState>((set, get) => ({
105
114
  },
106
115
  async login(request) {
107
116
  const response = await defLoginService.Login(request)
108
- if (response.status === 1 || response.status === 0 || response.status === 4) await get().applyLoginToken(response)
117
+ if (shouldApplyLoginToken(response)) await get().applyLoginToken(response)
109
118
  return response
110
119
  },
111
120
  async verifyMfa(request) {
112
121
  const response = await defMfaService.VerifyMfa(request)
113
- await get().applyLoginToken(response)
122
+ if (shouldApplyLoginToken(response)) await get().applyLoginToken(response)
114
123
  return response
115
124
  },
116
125
  async createOauthSession(request) {
117
126
  const response = await defOauthService.CreateOauthSession(request)
118
- if (!response.binding_required && (response.status === 1 || response.status === 0 || response.status === 4)) await get().applyLoginToken(response)
127
+ if (!response.binding_required && shouldApplyLoginToken(response)) await get().applyLoginToken(response)
119
128
  return response
120
129
  },
121
130
  async bindOauthSession(request) {
122
131
  const response = await defOauthService.BindOauthSession(request)
123
- if (response.status === 1 || response.status === 0 || response.status === 4) await get().applyLoginToken(response)
132
+ if (shouldApplyLoginToken(response)) await get().applyLoginToken(response)
124
133
  return response
125
134
  },
126
135
  async getUserProfile() {
@@ -131,8 +140,12 @@ export const useUserStore = create<UserStoreState>((set, get) => ({
131
140
  return profile
132
141
  },
133
142
  async logout() {
134
- await defLoginService.Logout({})
135
- await get().clearUserData()
143
+ try {
144
+ await defLoginService.Logout({})
145
+ } finally {
146
+ // 登出接口失败时仍清理本地登录态,避免残留有效 token 造成“假登录”。
147
+ await get().clearUserData()
148
+ }
136
149
  },
137
150
  async refreshToken() {
138
151
  const response = await defLoginService.RefreshToken({ refresh_token: getRefreshToken() })
package/src/utils/file.ts CHANGED
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro'
2
2
  import { BaseFileAccessMode, type FileInfo, type MultiUploadFileResponse } from '../rpc/base/v1/file'
3
3
  import { getLocaleRequestHeaders, t } from '../locales'
4
4
  import { formatSrc } from './index'
5
- import { getRequestAccessToken, requestBaseURL } from './http'
5
+ import { getRequestAccessToken, requestBaseURL, sourceClient } from './http'
6
6
 
7
7
  /** 上传单个文件。 */
8
8
  export async function uploadFile(
@@ -18,7 +18,7 @@ export async function uploadFile(
18
18
  formData: { fileType, accessMode: String(accessMode) },
19
19
  header: {
20
20
  ...getLocaleRequestHeaders(),
21
- 'source-client': 'miniapp',
21
+ 'source-client': sourceClient,
22
22
  ...(token ? { Authorization: token } : {}),
23
23
  },
24
24
  })
package/src/utils/http.ts CHANGED
@@ -18,7 +18,14 @@ const normalizedApiBasePath = apiBasePath.startsWith('/') ? apiBasePath : `/${ap
18
18
  const requestOrigin = process.env.TARO_ENV === 'h5' ? '' : apiTargetUrl.replace(/\/$/, '')
19
19
  /** 请求基础地址。 */
20
20
  export const requestBaseURL = `${requestOrigin}${normalizedApiBasePath}`
21
- const sourceClient = process.env.TARO_ENV === 'weapp' ? 'taro-weapp' : 'taro-h5'
21
+ /** 站点根地址(不含 API 基础路径),供事件流等非 API 端点复用。 */
22
+ export const siteBaseURL =
23
+ process.env.TARO_ENV === 'h5'
24
+ ? typeof window !== 'undefined'
25
+ ? window.location.origin
26
+ : ''
27
+ : apiTargetUrl.replace(/\/$/, '')
28
+ export const sourceClient = process.env.TARO_ENV === 'weapp' ? 'taro-weapp' : 'taro-h5'
22
29
 
23
30
  const SESSION_URL = '/v1/base/session'
24
31
  const REFRESH_TOKEN_URL = '/v1/base/token'
@@ -176,7 +183,13 @@ async function getAccessTokenByMode(authMode: AuthMode): Promise<string> {
176
183
  }
177
184
  return ''
178
185
  }
179
- if (shouldRefreshToken()) await handleTokenRefresh(authMode)
186
+ if (shouldRefreshToken()) {
187
+ try {
188
+ await handleTokenRefresh()
189
+ } catch {
190
+ // 刷新失败已撤销本地认证,交由下方 hasValidToken 分支按当前调用方 authMode 处理。
191
+ }
192
+ }
180
193
  if (hasValidToken()) return getToken()
181
194
  if (authMode === 'required') {
182
195
  await promptRelogin()
@@ -186,12 +199,12 @@ async function getAccessTokenByMode(authMode: AuthMode): Promise<string> {
186
199
  return ''
187
200
  }
188
201
 
189
- function handleTokenRefresh(authMode: AuthMode): Promise<void> {
202
+ function handleTokenRefresh(): Promise<void> {
190
203
  if (refreshTokenPromise) return refreshTokenPromise
191
204
  refreshTokenPromise = refreshAccessToken()
192
- .catch(async (error) => {
193
- if (authMode === 'required') await promptRelogin()
194
- else silentClearAuthData()
205
+ .catch((error) => {
206
+ // 刷新失败后立即撤销本地认证,避免弹窗等待期间后台请求反复提交旧刷新令牌。
207
+ silentClearAuthData()
195
208
  throw error
196
209
  })
197
210
  .finally(() => {
@@ -243,8 +256,7 @@ async function promptRelogin(): Promise<void> {
243
256
  })
244
257
  if (!modal.confirm) return
245
258
  await new Promise((resolve) => setTimeout(resolve, 80))
246
- clearToken()
247
- Taro.removeStorageSync('user')
259
+ silentClearAuthData()
248
260
  saveCurrentRoute()
249
261
  await Taro.reLaunch({ url: '/pages/login/login' })
250
262
  } finally {
@@ -37,16 +37,8 @@ const emptyLoginForm = (): LoginRequest => ({
37
37
 
38
38
  function isWechatUnboundError(error: unknown): boolean {
39
39
  if (!error || typeof error !== 'object') return false
40
- const response = error as {
41
- data?: {
42
- reason?: string | number
43
- message?: string
44
- binding_required?: boolean
45
- error?: { reason?: string | number; message?: string }
46
- }
47
- }
48
- const reason = response.data?.reason ?? response.data?.error?.reason
49
- return response.data?.binding_required === true || String(reason || '') === 'UNAUTHENTICATED'
40
+ const response = error as { data?: { binding_required?: boolean } }
41
+ return response.data?.binding_required === true
50
42
  }
51
43
 
52
44
  /** 提取小程序请求失败时后端返回的可展示消息。 */
@@ -120,6 +112,7 @@ export default function LoginPage() {
120
112
  const [mfaSetupWebAuthnOptionsJson, setMfaSetupWebAuthnOptionsJson] = useState('')
121
113
  const [mfaRecoveryCodesVisible, setMfaRecoveryCodesVisible] = useState(false)
122
114
  const [mfaRecoveryCodes, setMfaRecoveryCodes] = useState<string[]>([])
115
+ const shakeTimerRef = useRef<ReturnType<typeof setTimeout>>()
123
116
 
124
117
  const currentLanguageName =
125
118
  languageOptions.find((item) => item.language_code === locale)?.native_name || locale
@@ -178,7 +171,13 @@ export default function LoginPage() {
178
171
 
179
172
  useEffect(() => {
180
173
  void Taro.setNavigationBarTitle({ title: t('common.action.login') })
181
- }, [locale, t])
174
+ }, [locale])
175
+
176
+ useEffect(() => {
177
+ return () => {
178
+ if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current)
179
+ }
180
+ }, [])
182
181
 
183
182
  const updateForm = (key: keyof LoginRequest, value: string) => {
184
183
  setForm((current) => ({ ...current, [key]: value }))
@@ -279,7 +278,11 @@ export default function LoginPage() {
279
278
  }
280
279
  if (agreed) return true
281
280
  setShake(true)
282
- setTimeout(() => setShake(false), 500)
281
+ if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current)
282
+ shakeTimerRef.current = setTimeout(() => {
283
+ shakeTimerRef.current = undefined
284
+ setShake(false)
285
+ }, 500)
283
286
  const result = await Taro.showModal({
284
287
  title: t('common.title.notice'),
285
288
  content: t('core.login.protocol_prompt'),
@@ -4,6 +4,17 @@ import { useEffect, useState } from 'react'
4
4
  import './webview.scss'
5
5
  import { useI18n } from '../../../locales'
6
6
 
7
+ /** 只放行 http(s) 外链,其它协议按非法地址处理。 */
8
+ const isAllowedUrl = (value: string) => /^https?:\/\//i.test(value)
9
+
10
+ const safeDecodeURIComponent = (value: string) => {
11
+ try {
12
+ return decodeURIComponent(value)
13
+ } catch {
14
+ return value
15
+ }
16
+ }
17
+
7
18
  /** 统一外链承载页。 */
8
19
  export default function WebViewPage() {
9
20
  const { t } = useI18n()
@@ -13,9 +24,9 @@ export default function WebViewPage() {
13
24
  const isH5 = process.env.TARO_ENV === 'h5'
14
25
 
15
26
  useLoad((query) => {
16
- const nextUrl = decodeURIComponent(query?.url || '')
17
- const title = decodeURIComponent(query?.title || '')
18
- setUrl(nextUrl)
27
+ const nextUrl = safeDecodeURIComponent(query?.url || '')
28
+ setUrl(isAllowedUrl(nextUrl) ? nextUrl : '')
29
+ const title = safeDecodeURIComponent(query?.title || '')
19
30
  if (title) void Taro.setNavigationBarTitle({ title })
20
31
  })
21
32
 
@@ -35,11 +46,7 @@ export default function WebViewPage() {
35
46
  return (
36
47
  <View className='webview-container'>
37
48
  {url ? (
38
- <WebView
39
- src={url}
40
- onLoad={() => setIframeLoaded(true)}
41
- onMessage={(event) => console.log('收到H5消息:', event.detail)}
42
- />
49
+ <WebView src={url} onLoad={() => setIframeLoaded(true)} />
43
50
  ) : null}
44
51
  {showFallback ? (
45
52
  <View className='webview-empty'>