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:
yyh
2026-05-15 08:12:52 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent ff02636a4b
commit a252fbddfa
24 changed files with 1050 additions and 87 deletions
+8 -1
View File
@@ -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=')
})
})
})
+12 -2
View File
@@ -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',
},
})
})
})
})
})
+14 -5
View File
@@ -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',
})
})
})
})
})
+6 -1
View File
@@ -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={
+7 -1
View File
@@ -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 }> =>
+7 -1
View File
@@ -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 })
},
})
+7
View File
@@ -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
}