This commit is contained in:
yyh
2026-01-22 22:34:07 +08:00
parent 03faf8e91b
commit 3ca098eaa5
61 changed files with 388 additions and 426 deletions

View File

@@ -23,12 +23,14 @@ import AppSideBar from '@/app/components/app-sidebar'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import s from './style.module.css'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
@@ -108,7 +110,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode

View File

@@ -15,7 +15,7 @@ import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import {
fetchAppDetail,
updateAppSiteAccessToken,
@@ -25,6 +25,7 @@ import {
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
export type ICardViewProps = {
appId: string
@@ -126,7 +127,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
}) as Promise<App>,
)
if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
handleCallbackResult(err)
}

View File

@@ -112,7 +112,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])

View File

@@ -8,12 +8,14 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function CheckCode() {
const { t } = useTranslation()
@@ -41,7 +43,7 @@ export default function CheckCode() {
setIsLoading(true)
const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email))

View File

@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function MailAndCodeAuth() {
const { t } = useTranslation()
@@ -36,7 +38,7 @@ export default function MailAndCodeAuth() {
setIsLoading(true)
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))

View File

@@ -10,6 +10,7 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import { STORAGE_KEYS } from '@/config/storage-keys'
import {
checkEmailExisted,
resetEmail,
@@ -18,6 +19,7 @@ import {
} from '@/service/common'
import { useLogout } from '@/service/use-common'
import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
type Props = {
show: boolean
@@ -172,7 +174,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')

View File

@@ -10,9 +10,11 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { storage } from '@/utils/storage'
export type IAppSelector = {
isMobile: boolean
@@ -28,7 +30,7 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
resetUser()
// Tokens are now stored in cookies and cleared by backend

View File

@@ -2,7 +2,9 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
import CheckEmail from './components/check-email'
import FeedBack from './components/feed-back'
import VerifyEmail from './components/verify-email'
@@ -21,7 +23,7 @@ export default function DeleteAccount(props: DeleteAccountProps) {
const handleEmailCheckSuccess = useCallback(async () => {
try {
setShowVerifyEmail(true)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
}
catch (error) { console.error(error) }
}, [])

View File

@@ -17,11 +17,12 @@ import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useIsLogin } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
import { storage } from '@/utils/storage'
import {
OAUTH_AUTHORIZE_PENDING_KEY,
OAUTH_AUTHORIZE_PENDING_TTL,
REDIRECT_URL_KEY,
} from './constants'
@@ -31,7 +32,7 @@ function setItemWithExpiry(key: string, value: string, ttl: number) {
value,
expiry: dayjs().add(ttl, 'seconds').unix(),
}
localStorage.setItem(key, JSON.stringify(item))
storage.set(key, JSON.stringify(item))
}
function buildReturnUrl(pathname: string, search: string) {
@@ -86,7 +87,7 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
setItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
setItemWithExpiry(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`)
}
catch {

View File

@@ -7,13 +7,16 @@ import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { LEGACY_KEY_MIGRATIONS, STORAGE_KEYS } from '@/config/storage-keys'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { storage } from '@/utils/storage'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
storage.runMigrations(LEGACY_KEY_MIGRATIONS)
type AppInitializerProps = {
children: ReactNode
}
@@ -75,7 +78,7 @@ export const AppInitializer = ({
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes')
try {
const isFinished = await isSetupFinished()

View File

@@ -22,7 +22,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
@@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIcon from '../base/app-icon'
import AppOperations from './app-operations'
@@ -128,7 +129,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}

View File

@@ -30,8 +30,10 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import IdeaOutput from './idea-output'
import InstructionEditorInBasic from './instruction-editor'
import InstructionEditorInWorkflow from './instruction-editor-in-workflow'
@@ -83,9 +85,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
onFinished,
}) => {
const { t } = useTranslation()
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -178,9 +178,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel(localModel)
}
@@ -209,7 +207,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -218,7 +216,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const onGenerate = async () => {

View File

@@ -17,8 +17,10 @@ import Toast from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
import IdeaOutput from '../automatic/idea-output'
import InstructionEditor from '../automatic/instruction-editor-in-workflow'
@@ -62,9 +64,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
presence_penalty: 0,
frequency_penalty: 0,
}
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -115,7 +115,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -124,7 +124,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const onGenerate = async () => {
@@ -168,9 +168,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel({
...localModel,

View File

@@ -14,27 +14,20 @@ import {
} from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
AgentStrategy,
} from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { storage } from '@/utils/storage'
import { ORCHESTRATE_CHANGED } from './types'
export const useDebugWithSingleOrMultipleModel = (appId: string) => {
const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models')
const localeDebugWithSingleOrMultipleModelConfigs = storage.get<DebugWithSingleOrMultipleModelConfigs>(STORAGE_KEYS.CONFIG.DEBUG_MODELS)
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>({})
if (localeDebugWithSingleOrMultipleModelConfigs) {
try {
debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {}
}
catch (e) {
console.error(e)
}
}
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>(localeDebugWithSingleOrMultipleModelConfigs || {})
const [
debugWithMultipleModel,
@@ -55,7 +48,7 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => {
configs: modelConfigs,
}
debugWithSingleOrMultipleModelConfigs.current[appId] = value
localStorage.setItem('app-debug-with-single-or-multiple-models', JSON.stringify(debugWithSingleOrMultipleModelConfigs.current))
storage.set(STORAGE_KEYS.CONFIG.DEBUG_MODELS, debugWithSingleOrMultipleModelConfigs.current)
setDebugWithMultipleModel(value.multiple)
setMultipleModelConfigs(value.configs)
}, [appId])

View File

@@ -16,7 +16,7 @@ import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
@@ -25,6 +25,7 @@ import { useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
@@ -145,7 +146,7 @@ const Apps = ({
onSuccess()
if (app.app_id)
await handleCheckPluginDependencies(app.app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {

View File

@@ -4,7 +4,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude'
import { ToastContext } from '@/app/components/base/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { createApp } from '@/service/apps'
@@ -12,6 +11,8 @@ import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args)
@@ -142,7 +143,7 @@ describe('CreateAppModal', () => {
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1'))
await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1'))
await waitFor(() => expect(mockGetRedirection).toHaveBeenCalledWith(true, mockApp, mockPush))
})

View File

@@ -19,7 +19,7 @@ import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
@@ -27,6 +27,7 @@ import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
@@ -91,7 +92,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
onSuccess()
onClose()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, app, push)
}
catch (e: any) {

View File

@@ -15,7 +15,7 @@ import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import {
@@ -28,6 +28,7 @@ import {
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Uploader from './uploader'
type CreateFromDSLModalProps = {
@@ -130,7 +131,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
if (app_id)
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
@@ -190,7 +191,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
})
if (app_id)
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {

View File

@@ -5,10 +5,11 @@ import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { AppModeEnum } from '@/types/app'
import SwitchAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
@@ -257,7 +258,7 @@ describe('SwitchAppModal', () => {
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')
expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1')
expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow')
expect(mockReplace).not.toHaveBeenCalled()
})

View File

@@ -17,13 +17,14 @@ import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIconPicker from '../../base/app-icon-picker'
type SwitchAppModalProps = {
@@ -73,7 +74,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
setAppDetail()
if (removeOriginal)
await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(
isCurrentWorkspaceEditor,
{

View File

@@ -20,7 +20,7 @@ import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Toast, { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
@@ -33,6 +33,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var'
@@ -144,7 +145,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
if (onRefresh)
onRefresh()
onPlanInfoChanged()

View File

@@ -434,13 +434,15 @@ describe('List', () => {
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
it('should call refetch when refresh key is set in localStorage', async () => {
localStorage.setItem('v1:needRefreshAppList', '1')
render(<List />)
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
await vi.waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
expect(localStorage.getItem('v1:needRefreshAppList')).toBeNull()
})
})

View File

@@ -22,13 +22,14 @@ import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty'
@@ -134,8 +135,8 @@ const List: FC<Props> = ({
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
if (storage.get<string>(STORAGE_KEYS.APP.NEED_REFRESH_LIST) === '1') {
storage.remove(STORAGE_KEYS.APP.NEED_REFRESH_LIST)
refetch()
}
}, [refetch])

View File

@@ -11,9 +11,10 @@ import {
generationConversationName,
} from '@/service/share'
import { shareQueryKeys } from '@/service/use-share'
import { CONVERSATION_ID_INFO } from '../constants'
import { useChatWithHistory } from './hooks'
const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo'
vi.mock('@/hooks/use-app-favicon', () => ({
useAppFavicon: vi.fn(),
}))
@@ -120,14 +121,14 @@ const setConversationIdInfo = (appId: string, conversationId: string) => {
'DEFAULT': conversationId,
},
}
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value))
localStorage.setItem(CONVERSATION_ID_INFO_KEY, JSON.stringify(value))
}
// Scenario: useChatWithHistory integrates share queries for conversations and chat list.
describe('useChatWithHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.removeItem(CONVERSATION_ID_INFO)
localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
mockStoreState.appInfo = {
app_id: 'app-1',
custom_config: null,
@@ -144,7 +145,7 @@ describe('useChatWithHistory', () => {
})
afterEach(() => {
localStorage.removeItem(CONVERSATION_ID_INFO)
localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
})
// Scenario: share query results populate conversation lists and trigger chat list fetch.
@@ -268,7 +269,7 @@ describe('useChatWithHistory', () => {
// Assert
await waitFor(() => {
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO)
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY)
const parsed = storedValue ? JSON.parse(storedValue) : {}
const storedUserId = parsed['app-1']?.['user-1']
const storedDefaultId = parsed['app-1']?.DEFAULT

View File

@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client'
@@ -41,6 +42,7 @@ import {
useShareConversations,
} from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { storage } from '@/utils/storage'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
@@ -128,27 +130,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
const localState = localStorage.getItem('webappSidebarCollapse')
return localState === 'collapsed'
}
catch {
// localStorage may be disabled in private browsing mode or by security settings
// fallback to default value
return false
}
const localState = storage.get<string>(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE)
return localState === 'collapsed'
}
return false
})
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
try {
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch {
// localStorage may be disabled, continue without persisting state
}
storage.set(STORAGE_KEYS.APP.SIDEBAR_COLLAPSE, state ? 'collapsed' : 'expanded')
}
}, [appId, setSidebarCollapseState])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {

View File

@@ -1,2 +1,2 @@
export const CONVERSATION_ID_INFO = 'conversationIdInfo'
export const CONVERSATION_ID_INFO = 'v1:conversationIdInfo'
export const UUID_NIL = '00000000-0000-0000-0000-000000000000'

View File

@@ -11,9 +11,10 @@ import {
generationConversationName,
} from '@/service/share'
import { shareQueryKeys } from '@/service/use-share'
import { CONVERSATION_ID_INFO } from '../constants'
import { useEmbeddedChatbot } from './hooks'
const CONVERSATION_ID_INFO_KEY = 'v1:conversationIdInfo'
vi.mock('@/i18n-config/client', () => ({
changeLanguage: vi.fn().mockResolvedValue(undefined),
}))
@@ -113,7 +114,7 @@ const createConversationData = (overrides: Partial<AppConversationData> = {}): A
describe('useEmbeddedChatbot', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.removeItem(CONVERSATION_ID_INFO)
localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
mockStoreState.appInfo = {
app_id: 'app-1',
custom_config: null,
@@ -131,7 +132,7 @@ describe('useEmbeddedChatbot', () => {
})
afterEach(() => {
localStorage.removeItem(CONVERSATION_ID_INFO)
localStorage.removeItem(CONVERSATION_ID_INFO_KEY)
})
// Scenario: share query results populate conversation lists and trigger chat list fetch.
@@ -251,7 +252,7 @@ describe('useEmbeddedChatbot', () => {
// Assert
await waitFor(() => {
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO)
const storedValue = localStorage.getItem(CONVERSATION_ID_INFO_KEY)
const parsed = storedValue ? JSON.parse(storedValue) : {}
const storedUserId = parsed['app-1']?.['embedded-user-1']
const storedDefaultId = parsed['app-1']?.DEFAULT

View File

@@ -105,7 +105,7 @@ describe('PlanComp', () => {
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token'))
expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
expect(localStorage.removeItem).toHaveBeenCalledWith(`v1:${EDUCATION_VERIFYING_LOCALSTORAGE_ITEM}`)
})
it('shows modal when education verify fails', async () => {

View File

@@ -14,12 +14,13 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useEducationVerify } from '@/service/use-education'
import { storage } from '@/utils/storage'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import { Loading } from '../../base/icons/src/public/thought'
import { NUM_INFINITE } from '../config'
@@ -72,7 +73,7 @@ const PlanComp: FC<Props> = ({
if (isPending)
return
mutateAsync().then((res) => {
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
if (unmountedRef.current)
return
router.push(`/education-apply?token=${res.token}`)

View File

@@ -4,8 +4,9 @@ import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useBuiltInMetaDataFields, useCreateMetaData, useDatasetMetaData, useDeleteMetaData, useRenameMeta, useUpdateBuiltInStatus } from '@/service/knowledge/use-metadata'
import { isShowManageMetadataLocalStorageKey } from '../types'
import { storage } from '@/utils/storage'
import useCheckMetadataName from './use-check-metadata-name'
const useEditDatasetMetadata = ({
@@ -24,10 +25,10 @@ const useEditDatasetMetadata = ({
}] = useBoolean(false)
useEffect(() => {
const isShowManageMetadata = localStorage.getItem(isShowManageMetadataLocalStorageKey)
const isShowManageMetadata = storage.get<string>(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA)
if (isShowManageMetadata) {
showEditModal()
localStorage.removeItem(isShowManageMetadataLocalStorageKey)
storage.remove(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA)
}
}, [])

View File

@@ -7,12 +7,14 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import { STORAGE_KEYS } from '@/config/storage-keys'
import useTimestamp from '@/hooks/use-timestamp'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AddMetadataButton from '../add-metadata-button'
import InputCombined from '../edit-metadata-batch/input-combined'
import SelectMetadataModal from '../metadata-dataset/select-metadata-modal'
import { DataType, isShowManageMetadataLocalStorageKey } from '../types'
import { DataType } from '../types'
import Field from './field'
type Props = {
@@ -53,7 +55,7 @@ const InfoGroup: FC<Props> = ({
const { formatTime: formatTimestamp } = useTimestamp()
const handleMangeMetadata = () => {
localStorage.setItem(isShowManageMetadataLocalStorageKey, 'true')
storage.set(STORAGE_KEYS.UI.SHOW_MANAGE_METADATA, 'true')
router.push(`/datasets/${dataSetId}/documents`)
}

View File

@@ -23,6 +23,7 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
@@ -30,6 +31,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AccountAbout from '../account-about'
import GithubStar from '../github-star'
import Indicator from '../indicator'
@@ -55,13 +57,13 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
resetUser()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info
localStorage.removeItem('education-reverify-prev-expire-at')
localStorage.removeItem('education-reverify-has-noticed')
localStorage.removeItem('education-expired-has-noticed')
storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_PREV_EXPIRE_AT)
storage.remove(STORAGE_KEYS.EDUCATION.REVERIFY_HAS_NOTICED)
storage.remove(STORAGE_KEYS.EDUCATION.EXPIRED_HAS_NOTICED)
router.push('/signin')
}

View File

@@ -1,18 +1,20 @@
import { useState } from 'react'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { NOTICE_I18N } from '@/i18n-config/language'
import { storage } from '@/utils/storage'
const MaintenanceNotice = () => {
const locale = useLanguage()
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
const [showNotice, setShowNotice] = useState(() => storage.get<string>(STORAGE_KEYS.UI.HIDE_MAINTENANCE_NOTICE) !== '1')
const handleJumpNotice = () => {
window.open(NOTICE_I18N.href, '_blank')
}
const handleCloseNotice = () => {
localStorage.setItem('hide-maintenance-notice', '1')
storage.set(STORAGE_KEYS.UI.HIDE_MAINTENANCE_NOTICE, '1')
setShowNotice(false)
}

View File

@@ -2,6 +2,8 @@
import { useCountDown } from 'ahooks'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
export const COUNT_DOWN_TIME_MS = 59000
export const COUNT_DOWN_KEY = 'leftTime'
@@ -12,23 +14,23 @@ type CountdownProps = {
export default function Countdown({ onResend }: CountdownProps) {
const { t } = useTranslation()
const [leftTime, setLeftTime] = useState(() => Number(localStorage.getItem(COUNT_DOWN_KEY) || COUNT_DOWN_TIME_MS))
const [leftTime, setLeftTime] = useState(() => storage.getNumber(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS))
const [time] = useCountDown({
leftTime,
onEnd: () => {
setLeftTime(0)
localStorage.removeItem(COUNT_DOWN_KEY)
storage.remove(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME)
},
})
const resend = async function () {
setLeftTime(COUNT_DOWN_TIME_MS)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
onResend?.()
}
useEffect(() => {
localStorage.setItem(COUNT_DOWN_KEY, `${time}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, time)
}, [time])
return (

View File

@@ -12,9 +12,11 @@ import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
@@ -34,8 +36,6 @@ type FeaturedToolsProps = {
onInstallSuccess?: () => void
}
const STORAGE_KEY = 'workflow_tools_featured_collapsed'
const FeaturedTools = ({
plugins,
providerMap,
@@ -50,14 +50,14 @@ const FeaturedTools = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED)
if (stored !== null)
setIsCollapsed(stored === 'true')
}, [])
@@ -65,7 +65,7 @@ const FeaturedTools = ({
useEffect(() => {
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
storage.set(STORAGE_KEYS.WORKFLOW.TOOLS_FEATURED_COLLAPSED, String(isCollapsed))
}, [isCollapsed])
useEffect(() => {

View File

@@ -11,9 +11,11 @@ import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
@@ -30,8 +32,6 @@ type FeaturedTriggersProps = {
onInstallSuccess?: () => void | Promise<void>
}
const STORAGE_KEY = 'workflow_triggers_featured_collapsed'
const FeaturedTriggers = ({
plugins,
providerMap,
@@ -45,14 +45,14 @@ const FeaturedTriggers = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED)
if (stored !== null)
setIsCollapsed(stored === 'true')
}, [])
@@ -60,7 +60,7 @@ const FeaturedTriggers = ({
useEffect(() => {
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
storage.set(STORAGE_KEYS.WORKFLOW.TRIGGERS_FEATURED_COLLAPSED, String(isCollapsed))
}, [isCollapsed])
useEffect(() => {

View File

@@ -10,8 +10,10 @@ import { Trans, useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useRAGRecommendedPlugins } from '@/service/use-tools'
import { isServer } from '@/utils/client'
import { storage } from '@/utils/storage'
import { getMarketplaceUrl } from '@/utils/var'
import List from './list'
@@ -21,8 +23,6 @@ type RAGToolRecommendationsProps = {
onTagsChange: Dispatch<SetStateAction<string[]>>
}
const STORAGE_KEY = 'workflow_rag_recommendations_collapsed'
const RAGToolRecommendations = ({
viewType,
onSelect,
@@ -32,14 +32,14 @@ const RAGToolRecommendations = ({
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED)
return stored === 'true'
})
useEffect(() => {
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
const stored = storage.get<string>(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED)
if (stored !== null)
setIsCollapsed(stored === 'true')
}, [])
@@ -47,7 +47,7 @@ const RAGToolRecommendations = ({
useEffect(() => {
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
storage.set(STORAGE_KEYS.WORKFLOW.RAG_RECOMMENDATIONS_COLLAPSED, String(isCollapsed))
}, [isCollapsed])
const {

View File

@@ -26,7 +26,7 @@ const createPanelWidthManager = (storageKey: string) => {
describe('Workflow Panel Width Persistence', () => {
describe('Node Panel Width Management', () => {
const storageKey = 'workflow-node-panel-width'
const storageKey = 'v1:workflow-node-panel-width'
it('should save user resize to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
@@ -74,7 +74,7 @@ describe('Workflow Panel Width Persistence', () => {
describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => {
const storageKey = 'workflow-node-panel-width'
const storageKey = 'v1:workflow-node-panel-width'
// Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => {
@@ -89,7 +89,7 @@ describe('Workflow Panel Width Persistence', () => {
})
it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width'
const storageKey = 'v1:workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference
@@ -101,7 +101,7 @@ describe('Workflow Panel Width Persistence', () => {
describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
const manager = createPanelWidthManager('v1:workflow-node-panel-width')
// Rapid system adjustments
manager.updateWidth(300, 'system')
@@ -112,12 +112,12 @@ describe('Workflow Panel Width Persistence', () => {
manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1)
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:workflow-node-panel-width', '550')
})
it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width')
localStorage.setItem('v1:workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('v1:workflow-node-panel-width')
const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value
@@ -125,13 +125,13 @@ describe('Workflow Panel Width Persistence', () => {
// User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500)
expect(localStorage.getItem('workflow-node-panel-width')).toBe('500')
expect(localStorage.getItem('v1:workflow-node-panel-width')).toBe('500')
})
})
describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
const manager = createPanelWidthManager('v1:workflow-node-panel-width')
// Valid source values
manager.updateWidth(500, 'user')

View File

@@ -12,10 +12,12 @@ import {
import Toast from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { STORAGE_KEYS } from '@/config/storage-keys'
import useTheme from '@/hooks/use-theme'
import { useGenerateStructuredOutputRules } from '@/service/use-common'
import { ModelModeType, Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { useMittContext } from '../visual-editor/context'
import { useVisualEditorStore } from '../visual-editor/store'
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
@@ -36,9 +38,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
onApply,
crossAxisOffset,
}) => {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [open, setOpen] = useState(false)
const [view, setView] = useState(GeneratorView.promptEditor)
const [model, setModel] = useState<Model>(localModel || {
@@ -60,9 +60,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel(localModel)
}
@@ -95,7 +93,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -104,7 +102,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()

View File

@@ -27,7 +27,7 @@ const createMockLocalStorage = () => {
// Preview panel width logic
const createPreviewPanelManager = () => {
const storageKey = 'debug-and-preview-panel-width'
const storageKey = 'v1:debug-and-preview-panel-width'
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
@@ -63,7 +63,7 @@ describe('Debug and Preview Panel Width Persistence', () => {
const result = manager.updateWidth(450, 'user')
expect(result).toBe(450)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '450')
})
it('should not save system compression to localStorage', () => {
@@ -80,17 +80,17 @@ describe('Debug and Preview Panel Width Persistence', () => {
// Both user and system operations should behave consistently
manager.updateWidth(500, 'user')
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '500')
manager.updateWidth(200, 'system')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500')
})
})
describe('Dual Panel Scenario', () => {
it('should maintain independence from Node Panel', () => {
localStorage.setItem('workflow-node-panel-width', '600')
localStorage.setItem('debug-and-preview-panel-width', '450')
localStorage.setItem('v1:workflow-node-panel-width', '600')
localStorage.setItem('v1:debug-and-preview-panel-width', '450')
const manager = createPreviewPanelManager()
@@ -98,8 +98,8 @@ describe('Debug and Preview Panel Width Persistence', () => {
manager.updateWidth(200, 'system')
// Only preview panel storage key should be unaffected
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('v1:workflow-node-panel-width')).toBe('600')
})
it('should handle F12 scenario consistently', () => {
@@ -107,13 +107,13 @@ describe('Debug and Preview Panel Width Persistence', () => {
// User sets preference
manager.updateWidth(500, 'user')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500')
// F12 opens causing viewport compression
manager.updateWidth(180, 'system')
// User preference preserved
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
expect(localStorage.getItem('v1:debug-and-preview-panel-width')).toBe('500')
})
})
@@ -124,7 +124,7 @@ describe('Debug and Preview Panel Width Persistence', () => {
// Same 400px minimum as Node Panel
const result = manager.updateWidth(300, 'user')
expect(result).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '400')
})
it('should use same source parameter pattern', () => {
@@ -132,7 +132,7 @@ describe('Debug and Preview Panel Width Persistence', () => {
// Default to 'user' when source not specified
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:debug-and-preview-panel-width', '500')
// Explicit 'system' source
manager.updateWidth(300, 'system')

View File

@@ -13,13 +13,14 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
import { useToastContext } from '@/app/components/base/toast'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import {
useEducationAdd,
useInvalidateEducationStatus,
} from '@/service/use-education'
import { storage } from '@/utils/storage'
import DifyLogo from '../components/base/logo/dify-logo'
import RoleSelector from './role-selector'
import SearchInput from './search-input'
@@ -47,7 +48,7 @@ const EducationApplyAge = () => {
setShowModal(undefined)
onPlanInfoChanged()
updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
router.replace('/')
}

View File

@@ -10,14 +10,15 @@ import {
useState,
} from 'react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
import { storage } from '@/utils/storage'
import {
EDUCATION_RE_VERIFY_ACTION,
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from './constants'
dayjs.extend(utc)
@@ -133,7 +134,7 @@ const useEducationReverifyNotice = ({
export const useEducationInit = () => {
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal)
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
const educationVerifying = storage.get<string>(STORAGE_KEYS.EDUCATION.VERIFYING)
const searchParams = useSearchParams()
const educationVerifyAction = searchParams.get('action')
@@ -156,7 +157,7 @@ export const useEducationInit = () => {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes')
}
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
handleVerify()

View File

@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import { Triangle } from '@/app/components/base/icons/src/public/education'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useLogout } from '@/service/use-common'
import { storage } from '@/utils/storage'
const UserInfo = () => {
const router = useRouter()
@@ -15,7 +17,7 @@ const UserInfo = () => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')

View File

@@ -158,7 +158,7 @@ describe('InstallForm', () => {
render(<InstallForm />)
await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(localStorage.setItem).toHaveBeenCalledWith('v1:setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})

View File

@@ -13,12 +13,14 @@ import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { LICENSE_LINK } from '@/constants/link'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { cn } from '@/utils/classnames'
import { encryptPassword as encodePassword } from '@/utils/encryption'
import { storage } from '@/utils/storage'
import Loading from '../components/base/loading'
const accountFormSchema = z.object({
@@ -85,7 +87,7 @@ const InstallForm = () => {
useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => {
if (res.step === 'finished') {
localStorage.setItem('setup_status', 'finished')
storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished')
router.push('/signin')
}
else {

View File

@@ -9,10 +9,12 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown'
import { storage } from '@/utils/storage'
import { COUNT_DOWN_TIME_MS } from '../components/signin/countdown'
export default function CheckCode() {
const { t } = useTranslation()
@@ -40,7 +42,7 @@ export default function CheckCode() {
setIsLoading(true)
const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email))

View File

@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import { sendEMailLoginCode } from '@/service/common'
import { storage } from '@/utils/storage'
type MailAndCodeAuthProps = {
isInvite: boolean
@@ -40,7 +42,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
setIsLoading(true)
const ret = await sendEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))

View File

@@ -1,15 +1,17 @@
import type { ReadonlyURLSearchParams } from 'next/navigation'
import dayjs from 'dayjs'
import { OAUTH_AUTHORIZE_PENDING_KEY, REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/constants'
import { REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/constants'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
function getItemWithExpiry(key: string): string | null {
const itemStr = localStorage.getItem(key)
const itemStr = storage.get<string>(key)
if (!itemStr)
return null
try {
const item = JSON.parse(itemStr)
localStorage.removeItem(key)
storage.remove(key)
if (!item?.value)
return null
@@ -24,7 +26,7 @@ export const resolvePostLoginRedirect = (searchParams: ReadonlyURLSearchParams)
const redirectUrl = searchParams.get(REDIRECT_URL_KEY)
if (redirectUrl) {
try {
localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY)
storage.remove(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING)
return decodeURIComponent(redirectUrl)
}
catch (e) {
@@ -33,5 +35,5 @@ export const resolvePostLoginRedirect = (searchParams: ReadonlyURLSearchParams)
}
}
return getItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY)
return getItemWithExpiry(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING)
}

View File

@@ -5,6 +5,9 @@ export const STORAGE_KEYS = {
VARIABLE_INSPECT_PANEL_HEIGHT: 'workflow-variable-inspect-panel-height',
CANVAS_MAXIMIZE: 'workflow-canvas-maximize',
OPERATION_MODE: 'workflow-operation-mode',
RAG_RECOMMENDATIONS_COLLAPSED: 'workflow_rag_recommendations_collapsed',
TOOLS_FEATURED_COLLAPSED: 'workflow_tools_featured_collapsed',
TRIGGERS_FEATURED_COLLAPSED: 'workflow_triggers_featured_collapsed',
},
APP: {
SIDEBAR_COLLAPSE: 'webappSidebarCollapse',
@@ -18,6 +21,7 @@ export const STORAGE_KEYS = {
ACCESS_TOKEN: 'access_token',
REFRESH_LOCK: 'is_other_tab_refreshing',
LAST_REFRESH_TIME: 'last_refresh_time',
OAUTH_AUTHORIZE_PENDING: 'oauth_authorize_pending',
},
EDUCATION: {
VERIFYING: 'educationVerifying',
@@ -30,6 +34,44 @@ export const STORAGE_KEYS = {
DEBUG_MODELS: 'app-debug-with-single-or-multiple-models',
SETUP_STATUS: 'setup_status',
},
UI: {
THEME: 'theme',
ANTHROPIC_QUOTA_NOTICE: 'anthropic_quota_notice',
HIDE_MAINTENANCE_NOTICE: 'hide-maintenance-notice',
COUNTDOWN_LEFT_TIME: 'leftTime',
SHOW_MANAGE_METADATA: 'dify-isShowManageMetadata',
},
} as const
export type StorageKeys = typeof STORAGE_KEYS
export const LEGACY_KEY_MIGRATIONS: Array<{ old: string, new: string }> = [
{ old: 'workflow-node-panel-width', new: 'workflow-node-panel-width' },
{ old: 'debug-and-preview-panel-width', new: 'debug-and-preview-panel-width' },
{ old: 'workflow-variable-inspect-panel-height', new: 'workflow-variable-inspect-panel-height' },
{ old: 'workflow-canvas-maximize', new: 'workflow-canvas-maximize' },
{ old: 'workflow-operation-mode', new: 'workflow-operation-mode' },
{ old: 'workflow_rag_recommendations_collapsed', new: 'workflow_rag_recommendations_collapsed' },
{ old: 'workflow_tools_featured_collapsed', new: 'workflow_tools_featured_collapsed' },
{ old: 'workflow_triggers_featured_collapsed', new: 'workflow_triggers_featured_collapsed' },
{ old: 'webappSidebarCollapse', new: 'webappSidebarCollapse' },
{ old: 'needRefreshAppList', new: 'needRefreshAppList' },
{ old: 'app-detail-collapse-or-expand', new: 'app-detail-collapse-or-expand' },
{ old: 'conversationIdInfo', new: 'conversationIdInfo' },
{ old: 'access_token', new: 'access_token' },
{ old: 'is_other_tab_refreshing', new: 'is_other_tab_refreshing' },
{ old: 'last_refresh_time', new: 'last_refresh_time' },
{ old: 'oauth_authorize_pending', new: 'oauth_authorize_pending' },
{ old: 'educationVerifying', new: 'educationVerifying' },
{ old: 'education-reverify-prev-expire-at', new: 'education-reverify-prev-expire-at' },
{ old: 'education-reverify-has-noticed', new: 'education-reverify-has-noticed' },
{ old: 'education-expired-has-noticed', new: 'education-expired-has-noticed' },
{ old: 'auto-gen-model', new: 'auto-gen-model' },
{ old: 'app-debug-with-single-or-multiple-models', new: 'app-debug-with-single-or-multiple-models' },
{ old: 'setup_status', new: 'setup_status' },
{ old: 'theme', new: 'theme' },
{ old: 'anthropic_quota_notice', new: 'anthropic_quota_notice' },
{ old: 'hide-maintenance-notice', new: 'hide-maintenance-notice' },
{ old: 'leftTime', new: 'leftTime' },
{ old: 'dify-isShowManageMetadata', new: 'dify-isShowManageMetadata' },
]

View File

@@ -6,6 +6,7 @@ import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config'
import { isServer } from '@/utils/client'
import { storage } from '@/utils/storage'
export type TriggerEventsLimitModalPayload = {
usage: number
@@ -80,15 +81,10 @@ export const useTriggerEventsLimitModal = ({
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey])
return
let persistDismiss = true
const persistDismiss = storage.isAvailable()
let hasDismissed = false
try {
if (localStorage.getItem(storageKey) === '1')
hasDismissed = true
}
catch {
persistDismiss = false
}
if (storage.get<string>(storageKey) === '1')
hasDismissed = true
if (hasDismissed)
return
@@ -110,16 +106,9 @@ export const useTriggerEventsLimitModal = ({
const storageKey = showTriggerEventsLimitModal?.payload.storageKey
if (!storageKey)
return
if (showTriggerEventsLimitModal?.payload.persistDismiss) {
try {
localStorage.setItem(storageKey, '1')
return
}
catch {
// ignore error and fall back to in-memory guard
}
}
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
if (showTriggerEventsLimitModal?.payload.persistDismiss)
storage.set(storageKey, '1')
}, [showTriggerEventsLimitModal])
return {

View File

@@ -130,7 +130,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
expect(setItemSpy.mock.calls.length).toBeGreaterThan(0)
})
const [key, value] = setItemSpy.mock.calls[0]
expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-')
expect(key).toContain('v1:trigger-events-limit-dismissed-workspace-1-professional-3000-')
expect(value).toBe('1')
})

View File

@@ -30,15 +30,15 @@ import {
DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab,
} from '@/app/components/header/account-setting/constants'
import {
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import {
useAccountSettingModal,
usePricingModal,
} from '@/hooks/use-query-params'
import { storage } from '@/utils/storage'
import {
@@ -183,10 +183,10 @@ export const ModalContextProvider = ({
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
const handleCancelAccountSettingModal = () => {
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
const educationVerifying = storage.get<string>(STORAGE_KEYS.EDUCATION.VERIFYING)
if (educationVerifying === 'yes')
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
storage.remove(STORAGE_KEYS.EDUCATION.VERIFYING)
accountSettingCallbacksRef.current?.onCancelCallback?.()
accountSettingCallbacksRef.current = null

View File

@@ -19,6 +19,7 @@ import {
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ZENDESK_FIELD_IDS } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchCurrentPlanInfo } from '@/service/billing'
import {
useModelListByType,
@@ -28,6 +29,7 @@ import {
import {
useEducationStatus,
} from '@/service/use-education'
import { storage } from '@/utils/storage'
export type ProviderContextState = {
modelProviders: ModelProvider[]
@@ -200,7 +202,7 @@ export const ProviderContextProvider = ({
const { t } = useTranslation()
useEffect(() => {
if (localStorage.getItem('anthropic_quota_notice') === 'true')
if (storage.get<string>(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE) === 'true')
return
if (dayjs().isAfter(dayjs('2025-03-17')))
@@ -216,7 +218,7 @@ export const ProviderContextProvider = ({
message: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }),
duration: 60000,
onClose: () => {
localStorage.setItem('anthropic_quota_notice', 'true')
storage.set(STORAGE_KEYS.UI.ANTHROPIC_QUOTA_NOTICE, 'true')
},
})
}

View File

@@ -77,9 +77,6 @@
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@@ -87,11 +84,6 @@
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
@@ -106,9 +98,6 @@
}
},
"app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -123,25 +112,12 @@
"count": 1
}
},
"app/(shareLayout)/webapp-reset-password/page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/account/(commonLayout)/account-page/email-change-modal.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
@@ -151,43 +127,22 @@
"count": 1
}
},
"app/account/(commonLayout)/avatar.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/account/(commonLayout)/delete-account/components/verify-email.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/account/(commonLayout)/delete-account/index.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/account/oauth/authorize/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/account/oauth/authorize/page.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/app-initializer.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/app-sidebar/app-info.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -377,9 +332,6 @@
}
},
"app/components/app/configuration/config/automatic/get-automatic-res.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@@ -403,9 +355,6 @@
}
},
"app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@@ -505,9 +454,6 @@
}
},
"app/components/app/configuration/debug/hooks.tsx": {
"no-restricted-globals": {
"count": 2
},
"ts/no-explicit-any": {
"count": 3
}
@@ -561,11 +507,6 @@
"count": 1
}
},
"app/components/app/create-app-dialog/app-list/index.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/app/create-app-modal/index.spec.tsx": {
"no-restricted-properties": {
"count": 1
@@ -575,9 +516,6 @@
}
},
"app/components/app/create-app-modal/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
@@ -586,9 +524,6 @@
}
},
"app/components/app/create-from-dsl-modal/index.tsx": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
}
@@ -656,9 +591,6 @@
}
},
"app/components/app/switch-app-modal/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@@ -697,9 +629,6 @@
}
},
"app/components/apps/app-card.tsx": {
"no-restricted-globals": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
@@ -719,9 +648,6 @@
}
},
"app/components/apps/list.tsx": {
"no-restricted-globals": {
"count": 2
},
"unused-imports/no-unused-vars": {
"count": 1
}
@@ -852,9 +778,6 @@
}
},
"app/components/base/chat/chat-with-history/hooks.tsx": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
@@ -1633,9 +1556,6 @@
}
},
"app/components/billing/plan/index.tsx": {
"no-restricted-globals": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -1993,9 +1913,6 @@
}
},
"app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@@ -2015,11 +1932,6 @@
"count": 1
}
},
"app/components/datasets/metadata/metadata-document/info-group.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/components/datasets/settings/form/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -2125,11 +2037,6 @@
"count": 1
}
},
"app/components/header/account-dropdown/index.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/header/account-setting/data-source-page-new/card.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -2314,11 +2221,6 @@
"count": 1
}
},
"app/components/header/maintenance-notice.tsx": {
"no-restricted-globals": {
"count": 2
}
},
"app/components/plugins/install-plugin/hooks.ts": {
"ts/no-explicit-any": {
"count": 4
@@ -2782,11 +2684,6 @@
"count": 2
}
},
"app/components/signin/countdown.tsx": {
"no-restricted-globals": {
"count": 4
}
},
"app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2947,9 +2844,6 @@
}
},
"app/components/workflow/block-selector/featured-tools.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@@ -2958,9 +2852,6 @@
}
},
"app/components/workflow/block-selector/featured-triggers.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
@@ -2979,9 +2870,6 @@
}
},
"app/components/workflow/block-selector/rag-tool-recommendations/index.tsx": {
"no-restricted-properties": {
"count": 3
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
@@ -3601,9 +3489,6 @@
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
"no-restricted-globals": {
"count": 6
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
}
@@ -4230,15 +4115,7 @@
"count": 1
}
},
"app/education-apply/education-apply-page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/education-apply/hooks.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 5
}
@@ -4248,11 +4125,6 @@
"count": 1
}
},
"app/education-apply/user-info.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/education-apply/verify-state-modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -4276,26 +4148,11 @@
"count": 7
}
},
"app/install/installForm.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/reset-password/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/reset-password/page.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/signin/components/mail-and-code-auth.tsx": {
"no-restricted-globals": {
"count": 1
}
},
"app/signin/components/mail-and-password-auth.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -4311,11 +4168,6 @@
"count": 1
}
},
"app/signin/utils/post-login-redirect.ts": {
"no-restricted-globals": {
"count": 3
}
},
"app/signup/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -4327,9 +4179,6 @@
}
},
"context/hooks/use-trigger-events-limit-modal.ts": {
"no-restricted-globals": {
"count": 2
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
@@ -4346,17 +4195,11 @@
}
},
"context/modal-context.tsx": {
"no-restricted-globals": {
"count": 2
},
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
"no-restricted-globals": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -4374,11 +4217,6 @@
"count": 1
}
},
"hooks/use-import-dsl.ts": {
"no-restricted-globals": {
"count": 2
}
},
"hooks/use-metadata.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4545,11 +4383,6 @@
"count": 2
}
},
"service/refresh-token.ts": {
"no-restricted-properties": {
"count": 7
}
},
"service/share.ts": {
"ts/no-explicit-any": {
"count": 3
@@ -4614,11 +4447,6 @@
"count": 2
}
},
"service/webapp-auth.ts": {
"no-restricted-globals": {
"count": 6
}
},
"service/workflow-payload.ts": {
"ts/no-explicit-any": {
"count": 10
@@ -4735,11 +4563,6 @@
"count": 11
}
},
"utils/setup-status.ts": {
"no-restricted-globals": {
"count": 3
}
},
"utils/tool-call.spec.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4750,4 +4573,4 @@
"count": 2
}
}
}
}

View File

@@ -12,7 +12,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useSelector } from '@/context/app-context'
import { DSLImportStatus } from '@/models/app'
import {
@@ -20,6 +20,7 @@ import {
importDSLConfirm,
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { storage } from '@/utils/storage'
type DSLPayload = {
mode: DSLImportMode
@@ -83,7 +84,7 @@ export const useImportDSL = () => {
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
onSuccess?.()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
}
@@ -137,7 +138,7 @@ export const useImportDSL = () => {
message: t('newApp.appCreated', { ns: 'app' }),
})
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {

View File

@@ -1,13 +1,13 @@
import { API_PREFIX } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchWithRetry } from '@/utils'
const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
import { storage } from '@/utils/storage'
let isRefreshing = false
function waitUntilTokenRefreshed() {
return new Promise<void>((resolve) => {
function _check() {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
const isRefreshingSign = storage.get<string>(STORAGE_KEYS.AUTH.REFRESH_LOCK)
if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
setTimeout(() => {
_check()
@@ -23,35 +23,28 @@ function waitUntilTokenRefreshed() {
const isRefreshingSignAvailable = function (delta: number) {
const nowTime = new Date().getTime()
const lastTime = globalThis.localStorage.getItem('last_refresh_time') || '0'
const lastTime = storage.get<string>(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME) || '0'
return nowTime - Number.parseInt(lastTime) <= delta
}
// only one request can send
async function getNewAccessToken(timeout: number): Promise<void> {
try {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
const isRefreshingSign = storage.get<string>(STORAGE_KEYS.AUTH.REFRESH_LOCK)
if ((isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) || isRefreshing) {
await waitUntilTokenRefreshed()
}
else {
isRefreshing = true
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
storage.set(STORAGE_KEYS.AUTH.REFRESH_LOCK, '1')
storage.set(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME, new Date().getTime().toString())
globalThis.addEventListener('beforeunload', releaseRefreshLock)
// Do not use baseFetch to refresh tokens.
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
// it can lead to an infinite loop if the refresh attempt also returns 401.
// To avoid this, handle token refresh separately in a dedicated function
// that does not call baseFetch and uses a single retry mechanism.
const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST',
credentials: 'include', // Important: include cookies in the request
credentials: 'include',
headers: {
'Content-Type': 'application/json;utf-8',
},
// No body needed - refresh token is in cookie
}))
if (error) {
return Promise.reject(error)
@@ -72,11 +65,9 @@ async function getNewAccessToken(timeout: number): Promise<void> {
}
function releaseRefreshLock() {
// Always clear the refresh lock to avoid cross-tab deadlocks.
// This is safe to call multiple times and from tabs that were only waiting.
isRefreshing = false
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
globalThis.localStorage.removeItem('last_refresh_time')
storage.remove(STORAGE_KEYS.AUTH.REFRESH_LOCK)
storage.remove(STORAGE_KEYS.AUTH.LAST_REFRESH_TIME)
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
}

View File

@@ -1,28 +1,29 @@
import { ACCESS_TOKEN_LOCAL_STORAGE_NAME, PASSPORT_LOCAL_STORAGE_NAME } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
import { getPublic, postPublic } from './base'
export function setWebAppAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME, token)
storage.set(STORAGE_KEYS.AUTH.ACCESS_TOKEN, token)
}
export function setWebAppPassport(shareCode: string, token: string) {
localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token)
storage.set(`passport-${shareCode}`, token)
}
export function getWebAppAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) || ''
return storage.get<string>(STORAGE_KEYS.AUTH.ACCESS_TOKEN) || ''
}
export function getWebAppPassport(shareCode: string) {
return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || ''
return storage.get<string>(`passport-${shareCode}`) || ''
}
export function clearWebAppAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME)
storage.remove(STORAGE_KEYS.AUTH.ACCESS_TOKEN)
}
export function clearWebAppPassport(shareCode: string) {
localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode))
storage.remove(`passport-${shareCode}`)
}
type isWebAppLogin = {
@@ -31,8 +32,6 @@ type isWebAppLogin = {
}
export async function webAppLoginStatus(shareCode: string, userId?: string) {
// always need to check login to prevent passport from being outdated
// check remotely, the access token could be in cookie (enterprise SSO redirected with https)
const params = new URLSearchParams({ app_code: shareCode })
if (userId)
params.append('user_id', userId)

View File

@@ -19,7 +19,7 @@ describe('setup-status utilities', () => {
describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => {
it('should return cached finished status without API call', async () => {
localStorage.setItem('setup_status', 'finished')
localStorage.setItem('v1:setup_status', 'finished')
const result = await fetchSetupStatusWithCache()
@@ -28,11 +28,11 @@ describe('setup-status utilities', () => {
})
it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished')
localStorage.setItem('v1:setup_status', 'finished')
await fetchSetupStatusWithCache()
expect(localStorage.getItem('setup_status')).toBe('finished')
expect(localStorage.getItem('v1:setup_status')).toBe('finished')
})
})
@@ -45,7 +45,7 @@ describe('setup-status utilities', () => {
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBe('finished')
expect(localStorage.getItem('v1:setup_status')).toBe('finished')
})
it('should call API and remove cache when not finished', async () => {
@@ -56,24 +56,24 @@ describe('setup-status utilities', () => {
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
expect(localStorage.getItem('v1:setup_status')).toBeNull()
})
it('should clear stale cache when API returns not_started', async () => {
localStorage.setItem('setup_status', 'some_invalid_value')
localStorage.setItem('v1:setup_status', 'some_invalid_value')
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
expect(localStorage.getItem('v1:setup_status')).toBeNull()
})
})
describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => {
localStorage.setItem('setup_status', '')
localStorage.setItem('v1:setup_status', '')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
@@ -84,7 +84,7 @@ describe('setup-status utilities', () => {
})
it('should call API when cache value is not "finished"', async () => {
localStorage.setItem('setup_status', 'not_started')
localStorage.setItem('v1:setup_status', 'not_started')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
@@ -132,7 +132,7 @@ describe('setup-status utilities', () => {
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
expect(localStorage.getItem('setup_status')).toBeNull()
expect(localStorage.getItem('v1:setup_status')).toBeNull()
})
})
})

View File

@@ -1,10 +1,10 @@
import type { SetupStatusResponse } from '@/models/common'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { fetchSetupStatus } from '@/service/common'
const SETUP_STATUS_KEY = 'setup_status'
import { storage } from './storage'
const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
storage.get<string>(STORAGE_KEYS.CONFIG.SETUP_STATUS) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
@@ -13,9 +13,9 @@ export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse>
const status = await fetchSetupStatus()
if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
storage.set(STORAGE_KEYS.CONFIG.SETUP_STATUS, 'finished')
else
localStorage.removeItem(SETUP_STATUS_KEY)
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
return status
}

View File

@@ -3,6 +3,9 @@ import { isClient } from './client'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
const STORAGE_VERSION = 'v1'
const MIGRATION_FLAG_KEY = '__storage_migrated__'
let _isAvailable: boolean | null = null
function isLocalStorageAvailable(): boolean {
@@ -27,12 +30,52 @@ function isLocalStorageAvailable(): boolean {
}
}
function versionedKey(key: string): string {
return `${STORAGE_VERSION}:${key}`
}
function getRaw(key: string): string | null {
if (!isLocalStorageAvailable())
return null
try {
return localStorage.getItem(key)
}
catch {
return null
}
}
function setRaw(key: string, value: string): void {
if (!isLocalStorageAvailable())
return
try {
localStorage.setItem(key, value)
}
catch {
// Silent fail - localStorage may be full or disabled
}
}
function removeRaw(key: string): void {
if (!isLocalStorageAvailable())
return
try {
localStorage.removeItem(key)
}
catch {
// Silent fail
}
}
function get<T extends JsonValue>(key: string, defaultValue?: T): T | null {
if (!isLocalStorageAvailable())
return defaultValue ?? null
try {
const item = localStorage.getItem(key)
const item = localStorage.getItem(versionedKey(key))
if (item === null)
return defaultValue ?? null
@@ -54,7 +97,7 @@ function set<T extends JsonValue>(key: string, value: T): void {
try {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
localStorage.setItem(key, stringValue)
localStorage.setItem(versionedKey(key), stringValue)
}
catch {
// Silent fail - localStorage may be full or disabled
@@ -66,7 +109,7 @@ function remove(key: string): void {
return
try {
localStorage.removeItem(key)
localStorage.removeItem(versionedKey(key))
}
catch {
// Silent fail
@@ -97,6 +140,43 @@ function getBoolean(key: string, defaultValue?: boolean): boolean | null {
return value === 'true'
}
type MigrationEntry = { old: string, new: string }
function migrate(oldKey: string, newKey: string): boolean {
if (!isLocalStorageAvailable())
return false
const oldValue = getRaw(oldKey)
if (oldValue === null)
return false
const newVersionedKey = versionedKey(newKey)
if (getRaw(newVersionedKey) !== null)
return false
setRaw(newVersionedKey, oldValue)
removeRaw(oldKey)
return true
}
function runMigrations(migrations: MigrationEntry[]): void {
if (!isLocalStorageAvailable())
return
const migrationFlagValue = getRaw(MIGRATION_FLAG_KEY)
if (migrationFlagValue === STORAGE_VERSION)
return
for (const { old: oldKey, new: newKey } of migrations)
migrate(oldKey, newKey)
setRaw(MIGRATION_FLAG_KEY, STORAGE_VERSION)
}
function resetCache(): void {
_isAvailable = null
}
export const storage = {
get,
set,
@@ -104,4 +184,7 @@ export const storage = {
getNumber,
getBoolean,
isAvailable: isLocalStorageAvailable,
migrate,
runMigrations,
resetCache,
}

View File

@@ -134,7 +134,7 @@ const createMockLocalStorage = () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
@@ -142,4 +142,7 @@ beforeEach(() => {
writable: true,
configurable: true,
})
// Reset storage module cache to ensure fresh state for each test
const { storage } = await import('./utils/storage')
storage.resetCache()
})