0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

fix(ui): prevent infinite getPhrase api call (#3160)

This commit is contained in:
simeng-li 2023-02-21 14:22:22 +08:00 committed by GitHub
parent e3f88f5250
commit 03099debbb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 85 additions and 40 deletions

View file

@ -163,4 +163,16 @@ describe('when the application is not admin-console', () => {
expect(getPhrases).toBeCalledTimes(1);
expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]);
});
it('should call getPhrases with specific language is provided in params', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: customizedLanguage,
},
});
await expect(phraseRequest.get('/phrase?lng=fr')).resolves.toHaveProperty('status', 200);
expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]);
});
});

View file

@ -1,7 +1,9 @@
import { isBuiltInLanguageTag } from '@logto/phrases-ui';
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas';
import { object, string } from 'zod';
import detectLanguage from '#src/i18n/detect-language.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';
@ -24,26 +26,39 @@ export default function phraseRoutes<T extends AnonymousRouter>(
return languageInfo;
};
router.get('/phrase', async (ctx, next) => {
const interaction = await provider
.interactionDetails(ctx.req, ctx.res)
// Should not block when failed to get interaction
.catch(() => null);
router.get(
'/phrase',
koaGuard({
query: object({
lng: string().optional(),
}),
}),
async (ctx, next) => {
const interaction = await provider
.interactionDetails(ctx.req, ctx.res)
// Should not block when failed to get interaction
.catch(() => null);
const applicationId = interaction?.params.client_id;
const { autoDetect, fallbackLanguage } = await getLanguageInfo(applicationId);
const {
query: { lng },
} = ctx.guard;
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
const acceptableLanguages = [...detectedLanguages, fallbackLanguage];
const customLanguages = await findAllCustomLanguageTags();
const language =
acceptableLanguages.find(
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
) ?? 'en';
const applicationId = interaction?.params.client_id;
const { autoDetect, fallbackLanguage } = await getLanguageInfo(applicationId);
ctx.set('Content-Language', language);
ctx.body = await getPhrases(language, customLanguages);
const targetLanguage = lng ? [lng] : [];
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage];
const customLanguages = await findAllCustomLanguageTags();
const language =
acceptableLanguages.find(
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
) ?? 'en';
return next();
});
ctx.set('Content-Language', language);
ctx.body = await getPhrases(language, customLanguages);
return next();
}
);
}

View file

@ -47,7 +47,7 @@ const App = () => {
customCssRef.current.textContent = settings.customCss;
// Note: i18n must be initialized ahead of page render
await initI18n(settings.languageInfo);
await initI18n();
// Init the page settings and render
setExperienceSettings(settings);

View file

@ -11,17 +11,23 @@ export const getSignInExperience = async <T extends SignInExperienceResponse>():
return ky.get('/api/.well-known/sign-in-exp').json<T>();
};
export const getPhrases = async (lng?: string) =>
export const getPhrases = async ({
localLanguage,
language,
}: {
localLanguage?: string;
language?: string;
}) =>
ky
.extend({
hooks: {
beforeRequest: [
(request) => {
if (lng) {
request.headers.set('Accept-Language', lng);
if (localLanguage) {
request.headers.set('Accept-Language', localLanguage);
}
},
],
},
})
.get('/api/phrase');
.get(`/api/phrase${language ? `?lng=${language}` : ''}`);

View file

@ -23,12 +23,12 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
}
// Init i18n
void initI18n();
const i18nInit = initI18n();
// Block pointer event
document.body.classList.add(conditionalString(styles.preview));
const previewMessageHandler = (event: MessageEvent) => {
const previewMessageHandler = async (event: MessageEvent) => {
// TODO: @simeng: we can check allowed origins via `/.well-known/endpoints`
// if (event.origin !== window.location.origin) {
// return;
@ -36,6 +36,8 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
if (event.data.sender === 'ac_preview') {
// #event.data should be guarded at the provider's side
await i18nInit;
// eslint-disable-next-line no-restricted-syntax
setPreviewConfig(event.data.config as PreviewConfig);
}
@ -78,12 +80,20 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
setPlatform(platform);
await changeLanguage(language);
setExperienceSettings(experienceSettings);
})();
}, [isPreview, previewConfig, setExperienceSettings, setPlatform, setTheme]);
useEffect(() => {
if (!isPreview || !previewConfig?.language) {
return;
}
(async () => {
await changeLanguage(previewConfig.language);
})();
}, [previewConfig?.language, isPreview]);
return [isPreview, previewConfig];
};

View file

@ -1,17 +1,11 @@
import type { LanguageInfo } from '@logto/schemas';
import type { InitOptions } from 'i18next';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getI18nResource, detectLanguage } from '@/i18n/utils';
import { getI18nResource } from '@/i18n/utils';
const storageKey = 'i18nextLogtoUiLng';
const initI18n = async (languageSettings?: LanguageInfo) => {
// Get language settings from the SIE
const locale = detectLanguage(languageSettings);
const { resources, lng } = await getI18nResource(locale);
const initI18n = async () => {
const { resources, lng } = await getI18nResource();
const options: InitOptions = {
resources,

View file

@ -8,10 +8,18 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import { getPhrases } from '@/apis/settings';
export const getI18nResource = async (
locale?: string | string[]
language?: string
): Promise<{ resources: Resource; lng: string }> => {
const detectedLanguage = detectLanguage();
try {
const response = await getPhrases(Array.isArray(locale) ? locale.join(' ') : locale);
const response = await getPhrases({
localLanguage: Array.isArray(detectedLanguage)
? detectedLanguage.join(' ')
: detectedLanguage,
language,
});
const phrases = await response.json<LocalePhrase>();
const lng = response.headers.get('Content-Language');
@ -53,8 +61,8 @@ export const detectLanguage = (languageSettings?: LanguageInfo) => {
};
// Must be called after i18n's initialization
export const changeLanguage = async (targetLanguage: string) => {
const { resources, lng } = await getI18nResource(targetLanguage);
export const changeLanguage = async (language: string) => {
const { resources, lng } = await getI18nResource(language);
for (const [namespace, resource] of Object.entries(resources[lng] ?? {})) {
i18next.addResourceBundle(lng, namespace, resource);