Merge branch 'feat/change-user-email-freezes-limit' into deploy/dev

deploy/dev
JzoNg 7 months ago
commit f03afaf052

@ -113,3 +113,9 @@ class MemberNotInTenantError(BaseHTTPException):
error_code = "member_not_in_tenant" error_code = "member_not_in_tenant"
description = "The member is not in the workspace." description = "The member is not in the workspace."
code = 400 code = 400
class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
description = "This email is temporarily unavailable."
code = 400

@ -9,6 +9,7 @@ from configs import dify_config
from constants.languages import supported_language from constants.languages import supported_language
from controllers.console import api from controllers.console import api
from controllers.console.auth.error import ( from controllers.console.auth.error import (
AccountInFreezeError,
EmailAlreadyInUseError, EmailAlreadyInUseError,
EmailChangeLimitError, EmailChangeLimitError,
EmailCodeError, EmailCodeError,
@ -479,15 +480,18 @@ class ChangeEmailResetApi(Resource):
parser.add_argument("token", type=str, required=True, nullable=False, location="json") parser.add_argument("token", type=str, required=True, nullable=False, location="json")
args = parser.parse_args() args = parser.parse_args()
if AccountService.is_account_in_freeze(args["new_email"]):
raise AccountInFreezeError()
if not AccountService.check_email_unique(args["new_email"]):
raise EmailAlreadyInUseError()
reset_data = AccountService.get_change_email_data(args["token"]) reset_data = AccountService.get_change_email_data(args["token"])
if not reset_data: if not reset_data:
raise InvalidTokenError() raise InvalidTokenError()
AccountService.revoke_change_email_token(args["token"]) AccountService.revoke_change_email_token(args["token"])
if not AccountService.check_email_unique(args["new_email"]):
raise EmailAlreadyInUseError()
old_email = reset_data.get("old_email", "") old_email = reset_data.get("old_email", "")
if current_user.email != old_email: if current_user.email != old_email:
raise AccountNotFound() raise AccountNotFound()
@ -507,6 +511,8 @@ class CheckEmailUnique(Resource):
parser = reqparse.RequestParser() parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json") parser.add_argument("email", type=email, required=True, location="json")
args = parser.parse_args() args = parser.parse_args()
if AccountService.is_account_in_freeze(args["new_email"]):
raise AccountInFreezeError()
if not AccountService.check_email_unique(args["email"]): if not AccountService.check_email_unique(args["email"]):
raise EmailAlreadyInUseError() raise EmailAlreadyInUseError()
return {"result": "success"} return {"result": "success"}

@ -184,11 +184,10 @@ class ListOperatorNode(BaseNode):
value = int(self.graph_runtime_state.variable_pool.convert_template(self._node_data.extract_by.serial).text) value = int(self.graph_runtime_state.variable_pool.convert_template(self._node_data.extract_by.serial).text)
if value < 1: if value < 1:
raise ValueError(f"Invalid serial index: must be >= 1, got {value}") raise ValueError(f"Invalid serial index: must be >= 1, got {value}")
if value > len(variable.value):
raise InvalidKeyError(f"Invalid serial index: must be <= {len(variable.value)}, got {value}")
value -= 1 value -= 1
if len(variable.value) > int(value): result = variable.value[value]
result = variable.value[value]
else:
result = ""
return variable.model_copy(update={"value": [result]}) return variable.model_copy(update={"value": [result]})

@ -671,6 +671,12 @@ class AccountService:
return account return account
@classmethod
def is_account_in_freeze(cls, email: str) -> bool:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
return True
return False
@staticmethod @staticmethod
@redis_fallback(default_return=None) @redis_fallback(default_return=None)
def add_login_error_rate_limit(email: str) -> None: def add_login_error_rate_limit(email: str) -> None:

@ -15,6 +15,8 @@ import {
verifyEmail, verifyEmail,
} from '@/service/common' } from '@/service/common'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { asyncRunSafe } from '@/utils'
import type { ResponseError } from '@/service/fetch'
type Props = { type Props = {
show: boolean show: boolean
@ -39,6 +41,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const [time, setTime] = useState<number>(0) const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('') const [stepToken, setStepToken] = useState<string>('')
const [newEmailExited, setNewEmailExited] = useState<boolean>(false) const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
const [unAvailableEmail, setUnAvailableEmail] = useState<boolean>(false)
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false) const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
const startCount = () => { const startCount = () => {
@ -124,9 +127,17 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
email, email,
}) })
setNewEmailExited(false) setNewEmailExited(false)
setUnAvailableEmail(false)
} }
catch { catch (e: any) {
setNewEmailExited(true) if (e.status === 400) {
const [, errRespData] = await asyncRunSafe<ResponseError>(e.json())
const { code } = errRespData || {}
if (code === 'email_already_in_use')
setNewEmailExited(true)
if (code === 'account_in_freeze')
setUnAvailableEmail(true)
}
} }
finally { finally {
setIsCheckingEmail(false) setIsCheckingEmail(false)
@ -291,15 +302,18 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
placeholder={t('common.account.changeEmail.emailPlaceholder')} placeholder={t('common.account.changeEmail.emailPlaceholder')}
value={mail} value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)} onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited} destructive={newEmailExited || unAvailableEmail}
/> />
{newEmailExited && ( {newEmailExited && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div> <div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
)} )}
{unAvailableEmail && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.unAvailableEmail')}</div>
)}
</div> </div>
<div className='mt-3 space-y-2'> <div className='mt-3 space-y-2'>
<Button <Button
disabled={!mail || newEmailExited || isCheckingEmail || !isValidEmail(mail)} disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
className='!w-full' className='!w-full'
variant='primary' variant='primary'
onClick={sendCodeToNewEmail} onClick={sendCodeToNewEmail}

@ -18,7 +18,6 @@ const OptionListItem: FC<OptionListItemProps> = ({
useEffect(() => { useEffect(() => {
if (isSelected && !noAutoScroll) if (isSelected && !noAutoScroll)
listItemRef.current?.scrollIntoView({ behavior: 'instant' }) listItemRef.current?.scrollIntoView({ behavior: 'instant' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
return ( return (

@ -52,7 +52,6 @@ const TimePicker = ({
else { else {
setSelectedTime(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) setSelectedTime(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timezone]) }, [timezone])
const handleClickTrigger = (e: React.MouseEvent) => { const handleClickTrigger = (e: React.MouseEvent) => {

@ -56,7 +56,7 @@ const List = ({
return ( return (
<CardWrapper <CardWrapper
key={plugin.name} key={`${plugin.org}/${plugin.name}`}
plugin={plugin} plugin={plugin}
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
locale={locale} locale={locale}

@ -117,7 +117,6 @@ const PluginPage = ({
showInstallFromMarketplace() showInstallFromMarketplace()
} }
})() })()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [packageId, bundleInfo]) }, [packageId, bundleInfo])
const { const {

@ -1,14 +1,10 @@
import {
useEffect,
useRef,
} from 'react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { import {
RiArrowRightUpLine, RiArrowRightUpLine,
RiArrowUpDoubleLine, RiArrowUpDoubleLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMarketplace } from './hooks' import type { useMarketplace } from './hooks'
import List from '@/app/components/plugins/marketplace/list' import List from '@/app/components/plugins/marketplace/list'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { getLocaleOnClient } from '@/i18n' import { getLocaleOnClient } from '@/i18n'
@ -17,12 +13,16 @@ import { getMarketplaceUrl } from '@/utils/var'
type MarketplaceProps = { type MarketplaceProps = {
searchPluginText: string searchPluginText: string
filterPluginTags: string[] filterPluginTags: string[]
onMarketplaceScroll: () => void isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void
marketplaceContext: ReturnType<typeof useMarketplace>
} }
const Marketplace = ({ const Marketplace = ({
searchPluginText, searchPluginText,
filterPluginTags, filterPluginTags,
onMarketplaceScroll, isMarketplaceArrowVisible,
showMarketplacePanel,
marketplaceContext,
}: MarketplaceProps) => { }: MarketplaceProps) => {
const locale = getLocaleOnClient() const locale = getLocaleOnClient()
const { t } = useTranslation() const { t } = useTranslation()
@ -32,86 +32,76 @@ const Marketplace = ({
marketplaceCollections, marketplaceCollections,
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap,
plugins, plugins,
handleScroll,
page, page,
} = useMarketplace(searchPluginText, filterPluginTags) } = marketplaceContext
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (container)
container.addEventListener('scroll', handleScroll)
return () => {
if (container)
container.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
return ( return (
<div <>
ref={containerRef} <div className='sticky bottom-0 flex shrink-0 flex-col bg-background-default-subtle px-12 pb-[14px] pt-2'>
className='sticky bottom-[-442px] flex h-[530px] shrink-0 grow flex-col overflow-y-auto bg-background-default-subtle px-12 py-2 pt-0' {isMarketplaceArrowVisible && (
> <RiArrowUpDoubleLine
<RiArrowUpDoubleLine className='absolute left-1/2 top-2 z-10 h-4 w-4 -translate-x-1/2 cursor-pointer text-text-quaternary'
className='absolute left-1/2 top-2 h-4 w-4 -translate-x-1/2 cursor-pointer text-text-quaternary' onClick={showMarketplacePanel}
onClick={() => onMarketplaceScroll()} />
/> )}
<div className='sticky top-0 z-10 bg-background-default-subtle pb-3 pt-5'> <div className='pb-3 pt-4'>
<div className='title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent'> <div className='title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent'>
{t('plugin.marketplace.moreFrom')} {t('plugin.marketplace.moreFrom')}
</div> </div>
<div className='body-md-regular flex items-center text-center text-text-tertiary'> <div className='body-md-regular flex items-center text-center text-text-tertiary'>
{t('plugin.marketplace.discover')} {t('plugin.marketplace.discover')}
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> <span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.models')} {t('plugin.category.models')}
</span> </span>
, ,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> <span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.tools')} {t('plugin.category.tools')}
</span> </span>
, ,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> <span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.agents')} {t('plugin.category.agents')}
</span> </span>
, ,
<span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> <span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.extensions')} {t('plugin.category.extensions')}
</span> </span>
{t('plugin.marketplace.and')} {t('plugin.marketplace.and')}
<span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']"> <span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.bundles')} {t('plugin.category.bundles')}
</span> </span>
{t('common.operation.in')} {t('common.operation.in')}
<a <a
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })} href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })}
className='system-sm-medium ml-1 flex items-center text-text-accent' className='system-sm-medium ml-1 flex items-center text-text-accent'
target='_blank' target='_blank'
> >
{t('plugin.marketplace.difyMarketplace')} {t('plugin.marketplace.difyMarketplace')}
<RiArrowRightUpLine className='h-4 w-4' /> <RiArrowRightUpLine className='h-4 w-4' />
</a> </a>
</div>
</div> </div>
</div> </div>
{ <div className='mt-[-14px] shrink-0 grow bg-background-default-subtle px-12 pb-2'>
isLoading && page === 1 && ( {
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'> isLoading && page === 1 && (
<Loading /> <div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
</div> <Loading />
) </div>
} )
{ }
(!isLoading || page > 1) && ( {
<List (!isLoading || page > 1) && (
marketplaceCollections={marketplaceCollections || []} <List
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} marketplaceCollections={marketplaceCollections || []}
plugins={plugins} marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
showInstallButton plugins={plugins}
locale={locale} showInstallButton
/> locale={locale}
) />
} )
</div> }
</div>
</>
) )
} }

@ -1,5 +1,5 @@
'use client' 'use client'
import { useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { Collection } from './types' import type { Collection } from './types'
import Marketplace from './marketplace' import Marketplace from './marketplace'
@ -20,6 +20,7 @@ import { useAllToolProviders } from '@/service/use-tools'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { ToolTypeEnum } from '../workflow/block-selector/types' import { ToolTypeEnum } from '../workflow/block-selector/types'
import { useMarketplace } from './marketplace/hooks'
const getToolType = (type: string) => { const getToolType = (type: string) => {
switch (type) { switch (type) {
@ -37,7 +38,7 @@ const getToolType = (type: string) => {
} }
const ProviderList = () => { const ProviderList = () => {
// const searchParams = useSearchParams() // const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow' // searchParams.get('category') === 'workflow'
const { t } = useTranslation() const { t } = useTranslation()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -83,6 +84,41 @@ const ProviderList = () => {
return detail return detail
}, [currentProvider?.plugin_id, pluginList?.plugins]) }, [currentProvider?.plugin_id, pluginList?.plugins])
const toolListTailRef = useRef<HTMLDivElement>(null)
const showMarketplacePanel = useCallback(() => {
containerRef.current?.scrollTo({
top: toolListTailRef.current
? toolListTailRef.current?.offsetTop - 80
: 0,
behavior: 'smooth',
})
}, [toolListTailRef])
const marketplaceContext = useMarketplace(keywords, tagFilterValue)
const {
handleScroll,
} = marketplaceContext
const [isMarketplaceArrowVisible, setIsMarketplaceArrowVisible] = useState(true)
const onContainerScroll = useMemo(() => {
return (e: Event) => {
handleScroll(e)
if (containerRef.current && toolListTailRef.current)
setIsMarketplaceArrowVisible(containerRef.current.scrollTop < (toolListTailRef.current?.offsetTop - 80))
}
}, [handleScroll, containerRef, toolListTailRef, setIsMarketplaceArrowVisible])
useEffect(() => {
const container = containerRef.current
if (container)
container.addEventListener('scroll', onContainerScroll)
return () => {
if (container)
container.removeEventListener('scroll', onContainerScroll)
}
}, [onContainerScroll])
return ( return (
<> <>
<div className='relative flex h-0 shrink-0 grow overflow-hidden'> <div className='relative flex h-0 shrink-0 grow overflow-hidden'>
@ -152,15 +188,16 @@ const ProviderList = () => {
</div> </div>
)} )}
{!filteredCollectionList.length && activeTab === 'builtin' && ( {!filteredCollectionList.length && activeTab === 'builtin' && (
<Empty lightCard text={t('tools.noTools')} className='h-[224px] px-12' /> <Empty lightCard text={t('tools.noTools')} className='h-[224px] shrink-0 px-12' />
)} )}
<div ref={toolListTailRef} />
{enable_marketplace && activeTab === 'builtin' && ( {enable_marketplace && activeTab === 'builtin' && (
<Marketplace <Marketplace
onMarketplaceScroll={() => {
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' })
}}
searchPluginText={keywords} searchPluginText={keywords}
filterPluginTags={tagFilterValue} filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
/> />
)} )}
{activeTab === 'mcp' && ( {activeTab === 'mcp' && (

@ -248,6 +248,7 @@ const translation = {
emailLabel: 'New email', emailLabel: 'New email',
emailPlaceholder: 'Enter a new email', emailPlaceholder: 'Enter a new email',
existingEmail: 'A user with this email already exists.', existingEmail: 'A user with this email already exists.',
unAvailableEmail: 'This email is temporarily unavailable.',
sendVerifyCode: 'Send verification code', sendVerifyCode: 'Send verification code',
continue: 'Continue', continue: 'Continue',
changeTo: 'Change to {{email}}', changeTo: 'Change to {{email}}',

@ -50,24 +50,35 @@ export const loadLangResources = async (lang: string) => {
acc[camelCase(NAMESPACES[index])] = mod acc[camelCase(NAMESPACES[index])] = mod
return acc return acc
}, {} as Record<string, any>) }, {} as Record<string, any>)
return resources
}
const getFallbackTranslation = () => {
const resources = NAMESPACES.reduce((acc, ns, index) => {
acc[camelCase(NAMESPACES[index])] = require(`./en-US/${ns}`).default
return acc
}, {} as Record<string, any>)
return { return {
translation: resources, translation: resources,
} }
} }
i18n.use(initReactI18next) if (!i18n.isInitialized) {
.init({ i18n.use(initReactI18next)
lng: undefined, .init({
fallbackLng: 'en-US', lng: undefined,
}) fallbackLng: 'en-US',
resources: {
'en-US': getFallbackTranslation(),
},
})
}
export const changeLanguage = async (lng?: string) => { export const changeLanguage = async (lng?: string) => {
const resolvedLng = lng ?? 'en-US' const resolvedLng = lng ?? 'en-US'
const resources = { const resource = await loadLangResources(resolvedLng)
[resolvedLng]: await loadLangResources(resolvedLng),
}
if (!i18n.hasResourceBundle(resolvedLng, 'translation')) if (!i18n.hasResourceBundle(resolvedLng, 'translation'))
i18n.addResourceBundle(resolvedLng, 'translation', resources[resolvedLng].translation, true, true) i18n.addResourceBundle(resolvedLng, 'translation', resource, true, true)
await i18n.changeLanguage(resolvedLng) await i18n.changeLanguage(resolvedLng)
} }

@ -249,6 +249,7 @@ const translation = {
emailLabel: '新しいメール', emailLabel: '新しいメール',
emailPlaceholder: '新しいメールを入力してください', emailPlaceholder: '新しいメールを入力してください',
existingEmail: 'このメールアドレスのユーザーは既に存在します', existingEmail: 'このメールアドレスのユーザーは既に存在します',
unAvailableEmail: 'このメールアドレスは現在使用できません。',
sendVerifyCode: '確認コードを送信', sendVerifyCode: '確認コードを送信',
continue: '続行', continue: '続行',
changeTo: '{{email}} に変更', changeTo: '{{email}} に変更',

@ -248,6 +248,7 @@ const translation = {
emailLabel: '新邮箱', emailLabel: '新邮箱',
emailPlaceholder: '输入新邮箱', emailPlaceholder: '输入新邮箱',
existingEmail: '该邮箱已存在', existingEmail: '该邮箱已存在',
unAvailableEmail: '该邮箱暂时无法使用。',
sendVerifyCode: '发送验证码', sendVerifyCode: '发送验证码',
continue: '继续', continue: '继续',
changeTo: '更改为 {{email}}', changeTo: '更改为 {{email}}',

@ -519,7 +519,6 @@ export const usePluginTaskList = (category?: PluginType) => {
refreshPluginList(category ? { category } as any : undefined, !category) refreshPluginList(category ? { category } as any : undefined, !category)
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRefetching]) }, [isRefetching])
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {

Loading…
Cancel
Save