mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat: initialize user timezone and language from browser (#36170)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
||||
import { encryptVerificationCode } from '@/utils/encryption'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||
|
||||
export default function CheckCode() {
|
||||
@@ -39,7 +40,13 @@ export default function CheckCode() {
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
const ret = await emailLoginWithCode({ email, code: encryptVerificationCode(code), token, language })
|
||||
const ret = await emailLoginWithCode({
|
||||
email,
|
||||
code: encryptVerificationCode(code),
|
||||
token,
|
||||
language,
|
||||
timezone: getBrowserTimezone(),
|
||||
})
|
||||
if (ret.result === 'success') {
|
||||
// Track login success event
|
||||
trackEvent('user_login_success', {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import SocialAuth from '../social-auth'
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/timezone', () => ({
|
||||
getBrowserTimezone: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
const mockUseLocale = vi.mocked(useLocale)
|
||||
const mockGetBrowserTimezone = vi.mocked(getBrowserTimezone)
|
||||
|
||||
describe('SocialAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
mockUseLocale.mockReturnValue('zh-Hans')
|
||||
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render oauth provider links', () => {
|
||||
render(<SocialAuth />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth params', () => {
|
||||
it('should include browser timezone and locale in oauth links', () => {
|
||||
render(<SocialAuth />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('timezone=Asia%2FShanghai'),
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('language=zh-Hans'),
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('timezone=Asia%2FShanghai'),
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('language=zh-Hans'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve invite token when adding timezone', () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams('invite_token=invite-123') as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
render(<SocialAuth />)
|
||||
|
||||
const githubLink = screen.getByRole('link', { name: 'login.withGitHub' })
|
||||
expect(githubLink).toHaveAttribute('href', expect.stringContaining('invite_token=invite-123'))
|
||||
expect(githubLink).toHaveAttribute('href', expect.stringContaining('timezone=Asia%2FShanghai'))
|
||||
expect(githubLink).toHaveAttribute('href', expect.stringContaining('language=zh-Hans'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit timezone when browser timezone is unavailable', () => {
|
||||
mockGetBrowserTimezone.mockReturnValue(undefined)
|
||||
|
||||
render(<SocialAuth />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'login.withGitHub' }).getAttribute('href')).not.toContain('timezone=')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,10 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { getPurifyHref } from '@/utils'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import style from '../page.module.css'
|
||||
|
||||
type SocialAuthProps = {
|
||||
@@ -13,11 +15,19 @@ type SocialAuthProps = {
|
||||
export default function SocialAuth(props: SocialAuthProps) {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const locale = useLocale()
|
||||
|
||||
const getOAuthLink = (href: string) => {
|
||||
const url = getPurifyHref(`${API_PREFIX}${href}`)
|
||||
if (searchParams.has('invite_token'))
|
||||
return `${url}?${searchParams.toString()}`
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
const timezone = getBrowserTimezone()
|
||||
if (timezone)
|
||||
params.set('timezone', timezone)
|
||||
params.set('language', locale)
|
||||
|
||||
const query = params.toString()
|
||||
if (query)
|
||||
return `${url}?${query}`
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { activateMember } from '@/service/common'
|
||||
import { useInvitationCheck } from '@/service/use-common'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import InviteSettingsPage from '../page'
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: vi.fn(() => ({
|
||||
data: {
|
||||
branding: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
i18n: {
|
||||
defaultLocale: 'en-US',
|
||||
},
|
||||
setLocaleOnClient: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
activateMember: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useInvitationCheck: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/timezone', () => ({
|
||||
getBrowserTimezone: vi.fn(),
|
||||
timezones: [
|
||||
{ value: 'Asia/Shanghai', name: 'Asia/Shanghai' },
|
||||
{ value: 'America/Los_Angeles', name: 'America/Los_Angeles' },
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('../utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
|
||||
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
|
||||
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
|
||||
const mockActivateMember = activateMember as unknown as MockedFunction<typeof activateMember>
|
||||
const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction<typeof useInvitationCheck>
|
||||
const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone>
|
||||
|
||||
describe('InviteSettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseLocale.mockReturnValue('zh-Hans')
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams('invite_token=invite-token') as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
mockUseInvitationCheck.mockReturnValue({
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
|
||||
mockActivateMember.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
describe('Activation payload', () => {
|
||||
it('should default language to the current UI locale', async () => {
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
name: 'Invitee',
|
||||
interface_language: 'zh-Hans',
|
||||
timezone: 'Asia/Shanghai',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to configured default locale when current locale is unsupported', async () => {
|
||||
mockUseLocale.mockReturnValue('unsupported-locale' as ReturnType<typeof useLocale>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('login.name'), {
|
||||
target: { value: 'Invitee' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
name: 'Invitee',
|
||||
interface_language: 'en-US',
|
||||
timezone: 'Asia/Shanghai',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,14 +11,15 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { LICENSE_LINK } from '@/constants/link'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages, LanguagesSupported } from '@/i18n-config/language'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { i18n, setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { activateMember } from '@/service/common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInvitationCheck } from '@/service/use-common'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
import { getBrowserTimezone, timezones } from '@/utils/timezone'
|
||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||
|
||||
type LanguageSelectOption = {
|
||||
@@ -43,15 +44,23 @@ const TIMEZONE_OPTIONS: TimezoneSelectOption[] = timezones.map(item => ({
|
||||
name: item.name,
|
||||
}))
|
||||
|
||||
const getInitialLanguage = (locale: Locale): Locale => {
|
||||
if (LANGUAGE_OPTIONS.some(item => item.value === locale))
|
||||
return locale
|
||||
|
||||
return i18n.defaultLocale
|
||||
}
|
||||
|
||||
export default function InviteSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = decodeURIComponent(searchParams.get('invite_token') as string)
|
||||
const locale = useLocale()
|
||||
const [name, setName] = useState('')
|
||||
const [language, setLanguage] = useState(LanguagesSupported[0])
|
||||
const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles')
|
||||
const [language, setLanguage] = useState(() => getInitialLanguage(locale))
|
||||
const [timezone, setTimezone] = useState(() => getBrowserTimezone() || 'America/Los_Angeles')
|
||||
const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language)
|
||||
const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === timezone)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
import ChangePasswordForm from '../page'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMailRegister: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/timezone', () => ({
|
||||
getBrowserTimezone: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
rememberCreateAppExternalAttribution: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockRegister = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
|
||||
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
|
||||
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
|
||||
const mockUseMailRegister = useMailRegister as unknown as MockedFunction<typeof useMailRegister>
|
||||
const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone>
|
||||
|
||||
describe('Signup Set Password Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseLocale.mockReturnValue('zh-Hans')
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>)
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseMailRegister.mockReturnValue({
|
||||
mutateAsync: mockRegister,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useMailRegister>)
|
||||
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
|
||||
mockRegister.mockResolvedValue({ result: 'fail', data: {} })
|
||||
})
|
||||
|
||||
describe('Registration payload', () => {
|
||||
it('should submit locale and browser timezone when setting password', async () => {
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('common.account.newPassword'), {
|
||||
target: { value: 'ValidPass123!' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('common.account.confirmPassword'), {
|
||||
target: { value: 'ValidPass123!' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith({
|
||||
token: 'register-token',
|
||||
new_password: 'ValidPass123!',
|
||||
password_confirm: 'ValidPass123!',
|
||||
language: 'zh-Hans',
|
||||
timezone: 'Asia/Shanghai',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { validPassword } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { getBrowserTimezone } from '@/utils/timezone'
|
||||
|
||||
const parseUtmInfo = () => {
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
@@ -32,6 +34,7 @@ const ChangePasswordForm = () => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = decodeURIComponent(searchParams.get('token') || '')
|
||||
const locale = useLocale()
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
@@ -65,6 +68,8 @@ const ChangePasswordForm = () => {
|
||||
token,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
language: locale,
|
||||
timezone: getBrowserTimezone(),
|
||||
})
|
||||
const { result } = res as MailRegisterResponse
|
||||
if (result === 'success') {
|
||||
@@ -88,7 +93,7 @@ const ChangePasswordForm = () => {
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [password, token, valid, confirmPassword, register])
|
||||
}, [password, token, valid, confirmPassword, register, locale])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
|
||||
@@ -339,7 +339,13 @@ export const uploadRemoteFileInfo = (url: string, isPublic?: boolean, silent?: b
|
||||
export const sendEMailLoginCode = (email: string, language = 'en-US'): Promise<CommonResponse & { data: string }> =>
|
||||
post<CommonResponse & { data: string }>('/email-code-login', { body: { email, language } })
|
||||
|
||||
export const emailLoginWithCode = (data: { email: string, code: string, token: string, language: string }): Promise<LoginResponse> =>
|
||||
export const emailLoginWithCode = (data: {
|
||||
email: string
|
||||
code: string
|
||||
token: string
|
||||
language: string
|
||||
timezone?: string
|
||||
}): Promise<LoginResponse> =>
|
||||
post<LoginResponse>('/email-code-login/validity', { body: data })
|
||||
|
||||
export const sendResetPasswordCode = (email: string, language = 'en-US'): Promise<CommonResponse & { data: string, message?: string, code?: string }> =>
|
||||
|
||||
@@ -178,7 +178,13 @@ export type MailRegisterResponse = { result: string, data: {} }
|
||||
export const useMailRegister = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'mail-register'],
|
||||
mutationFn: (body: { token: string, new_password: string, password_confirm: string }) => {
|
||||
mutationFn: (body: {
|
||||
token: string
|
||||
new_password: string
|
||||
password_confirm: string
|
||||
language?: string
|
||||
timezone?: string
|
||||
}) => {
|
||||
return post<MailRegisterResponse>('/email-register', { body })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,3 +5,10 @@ type Item = {
|
||||
name: string
|
||||
}
|
||||
export const timezones: Item[] = tz
|
||||
|
||||
export const getBrowserTimezone = () => {
|
||||
if (typeof Intl === 'undefined')
|
||||
return undefined
|
||||
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user