0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

Merge pull request #2092 from logto-io/merge/manage-language

chore: merge `manage-language`
This commit is contained in:
Gao Sun 2022-10-10 18:09:12 +08:00 committed by GitHub
commit ee5fe004bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 2600 additions and 357 deletions

View file

@ -18,7 +18,8 @@
},
"devDependencies": {
"@fontsource/roboto-mono": "^4.5.7",
"@logto/core-kit": "^1.0.0-beta.13",
"@logto/core-kit": "1.0.0-beta.16",
"@logto/language-kit": "1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.10",
"@logto/phrases-ui": "^1.0.0-beta.10",
"@logto/react": "1.0.0-beta.8",
@ -43,9 +44,11 @@
"@types/react-modal": "^3.13.1",
"@types/react-syntax-highlighter": "^15.5.1",
"classnames": "^2.3.1",
"clean-deep": "^3.4.0",
"cross-env": "^7.0.3",
"csstype": "^3.0.11",
"dayjs": "^1.10.5",
"deepmerge": "^4.2.2",
"dnd-core": "^16.0.0",
"eslint": "^8.21.0",
"history": "^5.3.0",

View file

@ -0,0 +1,38 @@
import { builtInLanguages as builtInUiLanguages } from '@logto/phrases-ui';
import { useMemo } from 'react';
import useSWR from 'swr';
import { CustomPhraseResponse } from '@/types/custom-phrase';
import { RequestError } from './use-api';
const useUiLanguages = () => {
const {
data: customPhraseList,
error,
mutate,
} = useSWR<CustomPhraseResponse[], RequestError>('/api/custom-phrases');
const languages = useMemo(
() =>
[
...new Set([
...builtInUiLanguages,
...(customPhraseList?.map(({ languageTag }) => languageTag) ?? []),
]),
]
.slice()
.sort(),
[customPhraseList]
);
return {
customPhrases: customPhraseList,
languages,
error,
isLoading: !customPhraseList && !error,
mutate,
};
};
export default useUiLanguages;

View file

@ -1,10 +1,10 @@
import type { LanguageKey } from '@logto/core-kit';
import { LanguageTag } from '@logto/language-kit';
import resources from '@logto/phrases';
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
const initI18n = async (language?: LanguageKey) =>
const initI18n = async (language?: LanguageTag) =>
i18next
.use(initReactI18next)
.use(LanguageDetector)

View file

@ -1,13 +1,10 @@
// https://react.i18next.com/latest/typescript#create-a-declaration-file
import { Translation, Errors } from '@logto/phrases';
import { LocalPhrase } from '@logto/phrases';
declare module 'react-i18next' {
interface CustomTypeOptions {
allowObjectInHTMLChildren: true;
resources: {
translation: Translation;
errors: Errors;
};
resources: LocalPhrase;
}
}

View file

@ -1,5 +1,7 @@
import { getDefaultLanguage } from '@logto/core-kit';
import { languageOptions } from '@logto/phrases';
import {
builtInLanguageOptions as consoleBuiltInLanguageOptions,
getDefaultLanguageTag,
} from '@logto/phrases';
import { AppearanceMode } from '@logto/schemas';
import classNames from 'classnames';
import { Controller, useForm } from 'react-hook-form';
@ -25,7 +27,7 @@ const Settings = () => {
i18n: { language },
} = useTranslation(undefined, { keyPrefix: 'admin_console' });
const defaultLanguage = getDefaultLanguage(language);
const defaultLanguage = getDefaultLanguageTag(language);
const { data, error, update, isLoading, isLoaded } = useUserPreferences();
const {
@ -63,7 +65,7 @@ const Settings = () => {
render={({ field: { value, onChange } }) => (
<Select
value={value ?? defaultLanguage}
options={languageOptions}
options={consoleBuiltInLanguageOptions}
onChange={onChange}
/>
)}

View file

@ -1,60 +1,99 @@
import { languageOptions } from '@logto/phrases-ui';
import { languages as uiLanguageNameMapping } from '@logto/language-kit';
import { SignInExperience } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import FormField from '@/components/FormField';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Select from '@/components/Select';
import Switch from '@/components/Switch';
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
import { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
import { LanguageMode, SignInExperienceForm } from '../types';
import useLanguageEditorContext from '../hooks/use-language-editor-context';
import { SignInExperienceForm } from '../types';
import ManageLanguageModal from './ManageLanguageModal';
import * as styles from './index.module.scss';
const LanguagesForm = () => {
type Props = {
isManageLanguageVisible?: boolean;
};
const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { watch, control } = useFormContext<SignInExperienceForm>();
const mode = watch('languageInfo.mode');
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('/api/sign-in-exp');
const { watch, control, register, setValue } = useFormContext<SignInExperienceForm>();
const isAutoDetect = watch('languageInfo.autoDetect');
const selectedDefaultLanguage = watch('languageInfo.fallbackLanguage');
const [isManageLanguageFormOpen, setIsManageLanguageFormOpen] = useState(false);
const { languages } = useUiLanguages();
const languageOptions = useMemo(() => {
return languages.map((languageTag) => ({
value: languageTag,
title: uiLanguageNameMapping[languageTag],
}));
}, [languages]);
const { context: languageEditorContext, Provider: LanguageEditorContextProvider } =
useLanguageEditorContext(languages);
useEffect(() => {
if (!languages.includes(selectedDefaultLanguage)) {
setValue(
'languageInfo.fallbackLanguage',
signInExperience?.languageInfo.fallbackLanguage ?? 'en'
);
}
}, [languages, selectedDefaultLanguage, setValue, signInExperience]);
return (
<>
<div className={styles.title}>{t('sign_in_exp.others.languages.title')}</div>
<FormField title="sign_in_exp.others.languages.mode">
<Controller
name="languageInfo.mode"
control={control}
defaultValue={LanguageMode.Auto}
render={({ field: { onChange, value, name } }) => (
<RadioGroup value={value} name={name} onChange={onChange}>
<Radio value={LanguageMode.Auto} title="sign_in_exp.others.languages.auto" />
<Radio value={LanguageMode.Fixed} title="sign_in_exp.others.languages.fixed" />
</RadioGroup>
)}
<FormField title="sign_in_exp.others.languages.enable_auto_detect">
<Switch
{...register('languageInfo.autoDetect')}
label={t('sign_in_exp.others.languages.description')}
/>
</FormField>
{mode === LanguageMode.Auto && (
<FormField
title="sign_in_exp.others.languages.fallback_language"
tooltip="sign_in_exp.others.languages.fallback_language_tip"
{isManageLanguageVisible && (
// TODO: @yijun
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={classNames(textButtonStyles.button, styles.manageLanguage)}
onClick={() => {
setIsManageLanguageFormOpen(true);
}}
>
<Controller
name="languageInfo.fallbackLanguage"
control={control}
render={({ field: { value, onChange } }) => (
<Select value={value} options={languageOptions} onChange={onChange} />
)}
/>
</FormField>
)}
{mode === LanguageMode.Fixed && (
<FormField title="sign_in_exp.others.languages.fixed_language">
<Controller
name="languageInfo.fixedLanguage"
control={control}
render={({ field: { value, onChange } }) => (
<Select value={value} options={languageOptions} onChange={onChange} />
)}
/>
</FormField>
{t('sign_in_exp.others.languages.manage_language')}
</div>
)}
<FormField title="sign_in_exp.others.languages.default_language">
<Controller
name="languageInfo.fallbackLanguage"
control={control}
render={({ field: { value, onChange } }) => (
<Select value={value} options={languageOptions} onChange={onChange} />
)}
/>
<div className={styles.defaultLanguageDescription}>
{isAutoDetect
? t('sign_in_exp.others.languages.default_language_description_auto')
: t('sign_in_exp.others.languages.default_language_description_fixed')}
</div>
</FormField>
<LanguageEditorContextProvider value={languageEditorContext}>
<ManageLanguageModal
isOpen={isManageLanguageFormOpen}
languageTags={languages}
onClose={() => {
setIsManageLanguageFormOpen(false);
}}
/>
</LanguageEditorContextProvider>
</>
);
};

View file

@ -0,0 +1,58 @@
@use '@/scss/underscore' as _;
.languageSelector {
.input {
position: relative;
.addLanguageButton {
height: 38px;
width: 100%;
border-color: var(--color-outline);
color: var(--color-text);
background: unset;
}
.buttonIcon {
color: var(--color-outline);
}
}
.dropDown {
position: absolute;
width: 168px;
margin: _.unit(1) 0;
padding: _.unit(1);
background: var(--color-float);
border: 1px solid var(--color-divider);
border-radius: 8px;
max-height: 288px;
overflow-y: auto;
.dropDownItem {
width: 100%;
border-radius: _.unit(2);
padding: _.unit(2);
list-style: none;
cursor: pointer;
&:hover {
background: var(--color-hover);
}
.languageName {
font: var(--font-label-large);
color: var(--color-text);
}
.languageTag {
font: var(--font-body-medium);
color: var(--color-caption);
}
}
}
.hidden {
display: none;
}
}

View file

@ -0,0 +1,105 @@
import { LanguageTag, languages as uiLanguageNameMapping } from '@logto/language-kit';
import classNames from 'classnames';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import TextInput from '@/components/TextInput';
import Plus from '@/icons/Plus';
import SearchIcon from '@/icons/Search';
import * as style from './AddLanguageSelector.module.scss';
type Props = {
options: LanguageTag[];
onSelect: (languageTag: LanguageTag) => void;
};
const AddLanguageSelector = ({ options, onSelect }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const selectorRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [searchInputValue, setSearchInputValue] = useState('');
const filteredOptions = searchInputValue
? options.filter(
(languageTag) =>
languageTag.toLocaleLowerCase().includes(searchInputValue.toLocaleLowerCase()) ||
uiLanguageNameMapping[languageTag]
.toLocaleLowerCase()
.includes(searchInputValue.toLocaleLowerCase())
)
: options;
const clickOutsideHandler = ({ target }: MouseEvent) => {
if (target instanceof HTMLElement && !selectorRef.current?.contains(target)) {
setIsDropDownOpen(false);
setSearchInputValue('');
}
};
useEffect(() => {
if (isDropDownOpen) {
searchInputRef.current?.focus();
document.addEventListener('mousedown', clickOutsideHandler);
} else {
document.removeEventListener('mousedown', clickOutsideHandler);
}
return () => {
if (isDropDownOpen) {
document.removeEventListener('mousedown', clickOutsideHandler);
}
};
}, [isDropDownOpen, searchInputRef]);
return (
<div ref={selectorRef} className={style.languageSelector}>
<div className={style.input}>
<Button
className={classNames(style.addLanguageButton, isDropDownOpen && style.hidden)}
icon={<Plus className={style.buttonIcon} />}
title="sign_in_exp.others.manage_language.add_language"
type="outline"
size="medium"
onClick={() => {
setIsDropDownOpen(true);
}}
/>
<TextInput
ref={searchInputRef}
icon={<SearchIcon className={style.buttonIcon} />}
className={classNames(!isDropDownOpen && style.hidden)}
placeholder={t('general.type_to_search')}
value={searchInputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setSearchInputValue(event.target.value);
}}
/>
</div>
{isDropDownOpen && filteredOptions.length > 0 && (
<ul className={style.dropDown}>
{filteredOptions.map((languageTag) => (
// TODO: @yijun
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li
key={languageTag}
className={style.dropDownItem}
onClick={() => {
onSelect(languageTag);
setIsDropDownOpen(false);
setSearchInputValue('');
}}
>
<div className={style.languageName}>{uiLanguageNameMapping[languageTag]}</div>
<div className={style.languageTag}>{languageTag}</div>
</li>
))}
</ul>
)}
</div>
);
};
export default AddLanguageSelector;

View file

@ -0,0 +1,16 @@
@use '@/scss/underscore' as _;
.sectionTitle {
@include _.subhead-cap;
background-color: var(--color-layer-light);
}
.sectionDataKey {
padding: _.unit(4) _.unit(5);
font: var(--font-body-medium);
color: var(--color-text);
}
.sectionBuiltInText {
padding: _.unit(2) 0;
}

View file

@ -0,0 +1,42 @@
import { Translation } from '@logto/schemas';
import { useFormContext } from 'react-hook-form';
import TextInput from '@/components/TextInput';
import * as style from './EditSection.module.scss';
type EditSectionProps = {
dataKey: string;
data: Record<string, string>;
};
const EditSection = ({ dataKey, data }: EditSectionProps) => {
const { register } = useFormContext<Translation>();
return (
<>
<tr>
<td colSpan={3} className={style.sectionTitle}>
{dataKey}
</td>
</tr>
{Object.entries(data).map(([field, value]) => {
const fieldKey = `${dataKey}.${field}`;
return (
<tr key={fieldKey}>
<td className={style.sectionDataKey}>{field}</td>
<td>
<TextInput readOnly value={value} className={style.sectionBuiltInText} />
</td>
<td>
<TextInput {...register(fieldKey)} />
</td>
</tr>
);
})}
</>
);
};
export default EditSection;

View file

@ -0,0 +1,99 @@
@use '@/scss/underscore' as _;
.languageEditor {
flex-grow: 1;
.title {
padding: _.unit(6) _.unit(5);
font: var(--font-title-large);
color: var(--color-text);
display: flex;
justify-content: space-between;
.languageInfo {
display: flex;
align-items: center;
> span {
margin-left: _.unit(2);
font: var(--font-body-medium);
color: var(--color-caption);
}
.builtInFlag {
display: inline-block;
font: var(--font-label-medium);
color: var(--color-text);
background-color: var(--color-surface-variant);
padding: _.unit(0.5) _.unit(2);
border-radius: 10px;
}
}
}
.content {
border-top: 1px solid var(--color-border);
height: 481px;
overflow-y: auto;
> table {
border: none;
> thead > tr {
> th {
font: var(--font-label-large);
color: var(--color-text);
background-color: var(--color-layer-1);
}
> th:first-child {
width: 300px;
padding: _.unit(1) _.unit(5);
}
}
> tbody > tr {
> td {
border: none;
}
> td:first-child {
padding: _.unit(1) _.unit(5);
}
}
}
.customValuesColumn {
display: flex;
align-items: center;
}
.clearButton {
display: flex;
margin-left: _.unit(2);
flex-direction: row-reverse;
}
.sectionTitle {
@include _.subhead-cap;
background-color: var(--color-layer-light);
}
.sectionDataKey {
padding: _.unit(4) _.unit(5);
font: var(--font-body-medium);
color: var(--color-text);
}
.sectionBuiltInText {
padding: _.unit(2) 0;
}
}
.footer {
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: row-reverse;
padding: _.unit(5);
}
}

View file

@ -0,0 +1,263 @@
import { languages as uiLanguageNameMapping, LanguageTag } from '@logto/language-kit';
import resource, { isBuiltInLanguageTag } from '@logto/phrases-ui';
import en from '@logto/phrases-ui/lib/locales/en';
import { SignInExperience, Translation } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import deepmerge from 'deepmerge';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR, { useSWRConfig } from 'swr';
import Button from '@/components/Button';
import ConfirmModal from '@/components/ConfirmModal';
import IconButton from '@/components/IconButton';
import useApi, { RequestError } from '@/hooks/use-api';
import Delete from '@/icons/Delete';
import { CustomPhraseResponse } from '@/types/custom-phrase';
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
import { createEmptyUiTranslation, flattenTranslation } from '../../utilities';
import EditSection from './EditSection';
import * as style from './LanguageEditor.module.scss';
const emptyUiTranslation = createEmptyUiTranslation();
const LanguageEditor = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('/api/sign-in-exp');
const { languages, selectedLanguage, setIsDirty, setSelectedLanguage, stopAddingLanguage } =
useContext(LanguageEditorContext);
const [isDeletionAlertOpen, setIsDeletionAlertOpen] = useState(false);
const isBuiltIn = isBuiltInLanguageTag(selectedLanguage);
const isDefaultLanguage = signInExperience?.languageInfo.fallbackLanguage === selectedLanguage;
const translationEntries = useMemo(
() => Object.entries((isBuiltIn ? resource[selectedLanguage] : en).translation),
[isBuiltIn, selectedLanguage]
);
const { data: customPhrase, mutate } = useSWR<CustomPhraseResponse, RequestError>(
`/api/custom-phrases/${selectedLanguage}`,
{
shouldRetryOnError: (error: unknown) => {
if (error instanceof RequestError) {
return error.status !== 404;
}
return true;
},
}
);
const defaultFormValues = useMemo(
() => deepmerge(emptyUiTranslation, customPhrase?.translation ?? {}),
[customPhrase]
);
const formMethods = useForm<Translation>({
defaultValues: defaultFormValues,
});
const {
handleSubmit,
reset,
setValue,
formState: { isSubmitting, isDirty, dirtyFields },
} = formMethods;
useEffect(() => {
/**
* Note: This is a workaround for dirty state checking,
* for the `isDirty` state does not work correctly when comparing form data with empty / undefined values.
* Reference: https://github.com/react-hook-form/react-hook-form/issues/4740
*/
setIsDirty(isDirty && Object.keys(dirtyFields).length > 0);
}, [
/**
* Note: `isDirty` is used to trigger this `useEffect`; for `dirtyFields` object only marks filed dirty at field level.
* When `dirtyFields` is changed from `{keyA: false}` to `{keyA: true}`, this `useEffect` won't be triggered.
*/
isDirty,
dirtyFields,
setIsDirty,
]);
const { mutate: globalMutate } = useSWRConfig();
const api = useApi();
const upsertCustomPhrase = useCallback(
async (languageTag: LanguageTag, translation: Translation) => {
const updatedCustomPhrase = await api
.put(`/api/custom-phrases/${languageTag}`, {
json: {
...cleanDeep(translation),
},
})
.json<CustomPhraseResponse>();
void globalMutate('/api/custom-phrases');
stopAddingLanguage();
return updatedCustomPhrase;
},
[api, globalMutate, stopAddingLanguage]
);
const onDelete = useCallback(() => {
if (!customPhrase && !isDefaultLanguage) {
stopAddingLanguage(true);
setSelectedLanguage(
languages.find((languageTag) => languageTag !== selectedLanguage) ?? 'en'
);
return;
}
setIsDeletionAlertOpen(true);
}, [
customPhrase,
isDefaultLanguage,
languages,
selectedLanguage,
setSelectedLanguage,
stopAddingLanguage,
]);
const onConfirmDeletion = useCallback(async () => {
setIsDeletionAlertOpen(false);
if (isDefaultLanguage) {
return;
}
await api.delete(`/api/custom-phrases/${selectedLanguage}`);
await globalMutate('/api/custom-phrases');
setSelectedLanguage(languages.find((languageTag) => languageTag !== selectedLanguage) ?? 'en');
}, [api, globalMutate, isDefaultLanguage, languages, selectedLanguage, setSelectedLanguage]);
const onSubmit = handleSubmit(async (formData: Translation) => {
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
});
useEffect(() => {
reset(defaultFormValues);
}, [
/**
* Note: trigger form reset when selectedLanguage changed,
* for the `defaultValues` will not change when switching between languages with unavailable custom phrases.
*/
selectedLanguage,
defaultFormValues,
reset,
]);
return (
<div className={style.languageEditor}>
<div className={style.title}>
<div className={style.languageInfo}>
{uiLanguageNameMapping[selectedLanguage]}
<span>{selectedLanguage}</span>
{isBuiltIn && (
<span className={style.builtInFlag}>
{t('sign_in_exp.others.manage_language.logto_provided')}
</span>
)}
</div>
{!isBuiltIn && (
<IconButton onClick={onDelete}>
<Delete />
</IconButton>
)}
</div>
<form
onSubmit={async (event) => {
// Note: Avoid propagating the 'submit' event to the outer sign-in-experience form.
event.stopPropagation();
return onSubmit(event);
}}
>
<div className={style.content}>
<table>
<thead>
<tr>
<th>{t('sign_in_exp.others.manage_language.key')}</th>
<th>{t('sign_in_exp.others.manage_language.logto_source_language')}</th>
<th>
<span className={style.customValuesColumn}>
{t('sign_in_exp.others.manage_language.custom_values')}
<Button
type="plain"
title="sign_in_exp.others.manage_language.clear_all"
className={style.clearButton}
icon={<Delete />}
onClick={() => {
for (const [key, value] of Object.entries(
flattenTranslation(emptyUiTranslation)
)) {
setValue(key, value, { shouldDirty: true });
}
}}
/>
</span>
</th>
</tr>
</thead>
<tbody>
<FormProvider {...formMethods}>
{translationEntries.map(([key, value]) => (
<EditSection key={key} dataKey={key} data={flattenTranslation(value)} />
))}
</FormProvider>
</tbody>
</table>
</div>
<div className={style.footer}>
<Button
isLoading={isSubmitting}
htmlType="submit"
type="primary"
size="large"
title="general.save"
/>
</div>
</form>
<ConfirmModal
isOpen={isDeletionAlertOpen}
title={
isDefaultLanguage
? 'sign_in_exp.others.manage_language.default_language_deletion_title'
: 'sign_in_exp.others.manage_language.deletion_title'
}
confirmButtonText={
isDefaultLanguage ? 'sign_in_exp.others.manage_language.got_it' : 'general.delete'
}
confirmButtonType={isDefaultLanguage ? 'primary' : 'danger'}
onCancel={() => {
setIsDeletionAlertOpen(false);
}}
onConfirm={onConfirmDeletion}
>
{isDefaultLanguage
? t('sign_in_exp.others.manage_language.default_language_deletion_description', {
language: uiLanguageNameMapping[selectedLanguage],
})
: t('sign_in_exp.others.manage_language.deletion_description')}
</ConfirmModal>
</div>
);
};
export default LanguageEditor;

View file

@ -0,0 +1,31 @@
@use '@/scss/underscore' as _;
.languageItem {
padding: _.unit(1.5) _.unit(3);
margin-bottom: _.unit(1);
cursor: pointer;
border-radius: 8px;
.languageName {
font: var(--font-title-medium);
color: var(--color-text);
}
.languageTag {
font: var(--font-label-large);
color: var(--color-caption);
}
&:hover {
background-color: var(--color-hover-variant);
}
&.selected {
background-color: var(--color-focused-variant);
.languageName,
.languageTag {
color: var(--color-text-link);
}
}
}

View file

@ -0,0 +1,41 @@
import { languages, LanguageTag } from '@logto/language-kit';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import * as style from './LanguageItem.module.scss';
type Props = {
languageTag: LanguageTag;
isSelected: boolean;
onClick: () => void;
};
const LanguageItem = ({ languageTag, isSelected, onClick }: Props) => {
const itemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected) {
itemRef.current?.scrollIntoView(false);
}
}, [isSelected]);
return (
// TODO: @yijun
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
ref={itemRef}
className={classNames(style.languageItem, isSelected && style.selected)}
onClick={() => {
if (isSelected) {
return;
}
onClick();
}}
>
<div className={style.languageName}>{languages[languageTag]}</div>
<div className={style.languageTag}>{languageTag}</div>
</div>
);
};
export default LanguageItem;

View file

@ -0,0 +1,15 @@
@use '@/scss/underscore' as _;
.languageNav {
width: 185px;
padding: _.unit(3) _.unit(2);
flex-shrink: 0;
background-color: var(--color-layer-light);
border-right: 1px solid var(--color-border);
.languageItemList {
margin-top: _.unit(3);
height: 569px;
overflow-y: auto;
}
}

View file

@ -0,0 +1,72 @@
import {
isLanguageTag,
LanguageTag,
languages as uiLanguageNameMapping,
} from '@logto/language-kit';
import { useContext } from 'react';
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
import AddLanguageSelector from './AddLanguageSelector';
import LanguageItem from './LanguageItem';
import * as style from './LanguageNav.module.scss';
const LanguageNav = () => {
const {
languages,
selectedLanguage,
isAddingLanguage,
isDirty,
setConfirmationState,
setSelectedLanguage,
setPreSelectedLanguage,
setPreAddedLanguage,
startAddingLanguage,
} = useContext(LanguageEditorContext);
const languageOptions = Object.keys(uiLanguageNameMapping).filter(
(languageTag): languageTag is LanguageTag =>
isLanguageTag(languageTag) && !languages.includes(languageTag)
);
const onAddLanguage = (languageTag: LanguageTag) => {
if (isDirty || isAddingLanguage) {
setPreAddedLanguage(languageTag);
setConfirmationState('try-add-language');
return;
}
startAddingLanguage(languageTag);
};
const onSwitchLanguage = (languageTag: LanguageTag) => {
if (isDirty || isAddingLanguage) {
setPreSelectedLanguage(languageTag);
setConfirmationState('try-switch-language');
return;
}
setSelectedLanguage(languageTag);
};
return (
<div className={style.languageNav}>
<AddLanguageSelector options={languageOptions} onSelect={onAddLanguage} />
<div className={style.languageItemList}>
{languages.map((languageTag) => (
<LanguageItem
key={languageTag}
languageTag={languageTag}
isSelected={selectedLanguage === languageTag}
onClick={() => {
onSwitchLanguage(languageTag);
}}
/>
))}
</div>
</div>
);
};
export default LanguageNav;

View file

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: row;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}

View file

@ -0,0 +1,94 @@
import { LanguageTag } from '@logto/language-kit';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import ConfirmModal from '@/components/ConfirmModal';
import ModalLayout from '@/components/ModalLayout';
import * as modalStyles from '@/scss/modal.module.scss';
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
import LanguageEditor from './LanguageEditor';
import LanguageNav from './LanguageNav';
import * as style from './index.module.scss';
type ManageLanguageModalProps = {
isOpen: boolean;
languageTags: LanguageTag[];
onClose: () => void;
};
const ManageLanguageModal = ({ isOpen, languageTags, onClose }: ManageLanguageModalProps) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
preSelectedLanguage,
preAddedLanguage,
isAddingLanguage,
isDirty,
confirmationState,
setSelectedLanguage,
setPreSelectedLanguage,
setConfirmationState,
startAddingLanguage,
stopAddingLanguage,
} = useContext(LanguageEditorContext);
const onCloseModal = () => {
if (isAddingLanguage || isDirty) {
setConfirmationState('try-close');
return;
}
onClose();
setSelectedLanguage(languageTags[0] ?? 'en');
};
const onConfirmUnsavedChanges = () => {
stopAddingLanguage(true);
if (confirmationState === 'try-close') {
onClose();
}
if (confirmationState === 'try-switch-language' && preSelectedLanguage) {
setSelectedLanguage(preSelectedLanguage);
setPreSelectedLanguage(undefined);
}
if (confirmationState === 'try-add-language' && preAddedLanguage) {
startAddingLanguage(preAddedLanguage);
}
setConfirmationState('none');
};
return (
<Modal isOpen={isOpen} className={modalStyles.content} overlayClassName={modalStyles.overlay}>
<ModalLayout
title="sign_in_exp.others.manage_language.title"
subtitle="sign_in_exp.others.manage_language.subtitle"
size="xlarge"
onClose={onCloseModal}
>
<div className={style.container}>
<LanguageNav />
<LanguageEditor />
</div>
</ModalLayout>
<ConfirmModal
isOpen={confirmationState !== 'none'}
cancelButtonText="general.stay_on_page"
onCancel={() => {
setConfirmationState('none');
}}
onConfirm={onConfirmUnsavedChanges}
>
{t('sign_in_exp.others.manage_language.unsaved_description')}
</ConfirmModal>
</Modal>
);
};
export default ManageLanguageModal;

View file

@ -1,5 +1,4 @@
import type { LanguageKey } from '@logto/core-kit';
import { languageOptions } from '@logto/phrases-ui';
import { LanguageTag, languages as uiLanguageNameMapping } from '@logto/language-kit';
import {
AppearanceMode,
ConnectorResponse,
@ -17,6 +16,7 @@ import Card from '@/components/Card';
import Select from '@/components/Select';
import TabNav, { TabNavItem } from '@/components/TabNav';
import { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
import PhoneInfo from '@/icons/PhoneInfo';
import * as styles from './Preview.module.scss';
@ -28,11 +28,14 @@ type Props = {
const Preview = ({ signInExperience, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [language, setLanguage] = useState<LanguageKey>('en');
const [language, setLanguage] = useState<LanguageTag>('en');
const [mode, setMode] = useState<AppearanceMode>(AppearanceMode.LightMode);
const [platform, setPlatform] = useState<'desktopWeb' | 'mobile' | 'mobileWeb'>('desktopWeb');
const { data: allConnectors } = useSWR<ConnectorResponse[], RequestError>('/api/connectors');
const previewRef = useRef<HTMLIFrameElement>(null);
const { customPhrases } = useUiLanguages();
const { languages } = useUiLanguages();
const modeOptions = useMemo(() => {
const light = { value: AppearanceMode.LightMode, title: t('sign_in_exp.preview.light') };
@ -56,14 +59,18 @@ const Preview = ({ signInExperience, className }: Props) => {
}, [modeOptions, mode]);
const availableLanguageOptions = useMemo(() => {
if (signInExperience && !signInExperience.languageInfo.autoDetect) {
return languageOptions.filter(
({ value }) => value === signInExperience.languageInfo.fixedLanguage
);
}
const availableLanguageTags =
signInExperience && !signInExperience.languageInfo.autoDetect
? languages.filter(
(languageTag) => languageTag === signInExperience.languageInfo.fallbackLanguage
)
: languages;
return languageOptions;
}, [signInExperience]);
return availableLanguageTags.map((languageTag) => ({
value: languageTag,
title: uiLanguageNameMapping[languageTag],
}));
}, [languages, signInExperience]);
useEffect(() => {
if (!availableLanguageOptions[0]) {
@ -103,7 +110,7 @@ const Preview = ({ signInExperience, className }: Props) => {
}, [allConnectors, language, mode, platform, signInExperience]);
const postPreviewMessage = useCallback(() => {
if (!config) {
if (!config || !customPhrases) {
return;
}
@ -111,7 +118,7 @@ const Preview = ({ signInExperience, className }: Props) => {
{ sender: 'ac_preview', config },
window.location.origin
);
}, [config]);
}, [config, customPhrases]);
useEffect(() => {
postPreviewMessage();

View file

@ -28,3 +28,13 @@
font: var(--font-body-medium);
color: var(--color-caption);
}
.manageLanguage {
margin-top: _.unit(2);
}
.defaultLanguageDescription {
padding-top: _.unit(2);
font: var(--font-body-medium);
color: var(--color-caption);
}

View file

@ -0,0 +1,116 @@
import { LanguageTag } from '@logto/language-kit';
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
const noop = () => {
throw new Error('Context provider not found');
};
export type ConfirmationState = 'none' | 'try-close' | 'try-switch-language' | 'try-add-language';
export type Context = {
languages: LanguageTag[];
selectedLanguage: LanguageTag;
preSelectedLanguage?: LanguageTag;
preAddedLanguage?: LanguageTag;
isAddingLanguage: boolean;
isDirty: boolean;
confirmationState: ConfirmationState;
setSelectedLanguage: React.Dispatch<React.SetStateAction<LanguageTag>>;
setPreSelectedLanguage: React.Dispatch<React.SetStateAction<LanguageTag | undefined>>;
setPreAddedLanguage: React.Dispatch<React.SetStateAction<LanguageTag | undefined>>;
setIsDirty: React.Dispatch<React.SetStateAction<boolean>>;
setConfirmationState: React.Dispatch<React.SetStateAction<ConfirmationState>>;
startAddingLanguage: (languageTag: LanguageTag) => void;
stopAddingLanguage: (isCanceled?: boolean) => void;
};
export const LanguageEditorContext = createContext<Context>({
languages: [],
selectedLanguage: 'en',
preSelectedLanguage: undefined,
preAddedLanguage: undefined,
isAddingLanguage: false,
isDirty: false,
confirmationState: 'none',
setSelectedLanguage: noop,
setPreSelectedLanguage: noop,
setPreAddedLanguage: noop,
setIsDirty: noop,
setConfirmationState: noop,
startAddingLanguage: noop,
stopAddingLanguage: noop,
});
const useLanguageEditorContext = (defaultLanguages: LanguageTag[]) => {
const [languages, setLanguages] = useState(defaultLanguages);
useEffect(() => {
setLanguages(defaultLanguages);
}, [defaultLanguages]);
const [selectedLanguage, setSelectedLanguage] = useState<LanguageTag>(languages[0] ?? 'en');
const [preSelectedLanguage, setPreSelectedLanguage] = useState<LanguageTag>();
const [preAddedLanguage, setPreAddedLanguage] = useState<LanguageTag>();
const [isAddingLanguage, setIsAddingLanguage] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [confirmationState, setConfirmationState] = useState<ConfirmationState>('none');
const startAddingLanguage = useCallback(
(language: LanguageTag) => {
setLanguages([...new Set([language, ...defaultLanguages])].slice().sort());
setSelectedLanguage(language);
setIsAddingLanguage(true);
},
[defaultLanguages]
);
const stopAddingLanguage = useCallback(
(isCanceled = false) => {
if (isAddingLanguage) {
if (isCanceled) {
setLanguages(defaultLanguages);
setSelectedLanguage(languages[0] ?? 'en');
}
setIsAddingLanguage(false);
}
},
[defaultLanguages, isAddingLanguage, languages]
);
const context = useMemo<Context>(
() => ({
languages,
selectedLanguage,
preSelectedLanguage,
preAddedLanguage,
isAddingLanguage,
isDirty,
confirmationState,
setSelectedLanguage,
setPreSelectedLanguage,
setPreAddedLanguage,
setIsDirty,
setConfirmationState,
startAddingLanguage,
stopAddingLanguage,
}),
[
confirmationState,
isAddingLanguage,
isDirty,
languages,
preAddedLanguage,
preSelectedLanguage,
selectedLanguage,
startAddingLanguage,
stopAddingLanguage,
]
);
return {
context,
Provider: LanguageEditorContext.Provider,
};
};
export default useLanguageEditorContext;

View file

@ -14,6 +14,7 @@ import ConfirmModal from '@/components/ConfirmModal';
import TabNav, { TabNavItem } from '@/components/TabNav';
import useApi, { RequestError } from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import useUiLanguages from '@/hooks/use-ui-languages';
import * as detailsStyles from '@/scss/details.module.scss';
import Preview from './components/Preview';
@ -33,6 +34,7 @@ const SignInExperience = () => {
const { tab } = useParams();
const { data, error, mutate } = useSWR<SignInExperienceType, RequestError>('/api/sign-in-exp');
const { settings, error: settingsError, updateSettings, mutate: mutateSettings } = useSettings();
const { error: languageError, isLoading: isLoadingLanguages } = useUiLanguages();
const [dataToCompare, setDataToCompare] = useState<SignInExperienceType>();
const methods = useForm<SignInExperienceForm>();
@ -90,7 +92,7 @@ const SignInExperience = () => {
await saveData();
});
if ((!settings && !settingsError) || (!data && !error)) {
if ((!settings && !settingsError) || (!data && !error) || isLoadingLanguages) {
return <Skeleton />;
}
@ -98,6 +100,10 @@ const SignInExperience = () => {
return <div>{settingsError.body?.message ?? settingsError.message}</div>;
}
if (languageError) {
return <div>{languageError.body?.message ?? languageError.message}</div>;
}
if (!settings?.signInExperienceCustomized) {
return (
<Welcome

View file

@ -25,7 +25,7 @@ const OthersTab = ({ defaultData, isDataDirty }: Props) => {
return (
<>
<TermsForm />
<LanguagesForm />
<LanguagesForm isManageLanguageVisible />
<AuthenticationForm />
<UnsavedChangesAlertModal hasUnsavedChanges={isDataDirty} />
</>

View file

@ -1,12 +1,6 @@
import { LanguageKey } from '@logto/core-kit';
import { SignInExperience, SignInMethodKey } from '@logto/schemas';
export enum LanguageMode {
Auto = 'Auto',
Fixed = 'Fixed',
}
export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods' | 'languageInfo'> & {
export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods'> & {
signInMethods: {
primary?: SignInMethodKey;
enableSecondary: boolean;
@ -15,10 +9,5 @@ export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods' | 'lan
email: boolean;
social: boolean;
};
languageInfo: {
mode: LanguageMode;
fixedLanguage: LanguageKey;
fallbackLanguage: LanguageKey;
};
createAccountEnabled: boolean;
};

View file

@ -1,13 +1,15 @@
import en from '@logto/phrases-ui/lib/locales/en';
import {
SignInExperience,
SignInMethodKey,
SignInMethods,
SignInMethodState,
SignInMode,
Translation,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { LanguageMode, SignInExperienceForm } from './types';
import { SignInExperienceForm } from './types';
const findMethodState = (
setup: SignInExperienceForm,
@ -40,10 +42,7 @@ export const signInExperienceParser = {
(key) => signInExperience.signInMethods[key] === SignInMethodState.Secondary
);
const {
languageInfo: { autoDetect, fallbackLanguage, fixedLanguage },
signInMode,
} = signInExperience;
const { signInMode } = signInExperience;
return {
...signInExperience,
@ -55,20 +54,11 @@ export const signInExperienceParser = {
email: secondaryMethods.includes(SignInMethodKey.Email),
social: secondaryMethods.includes(SignInMethodKey.Social),
},
languageInfo: {
mode: autoDetect ? LanguageMode.Auto : LanguageMode.Fixed,
fallbackLanguage,
fixedLanguage,
},
createAccountEnabled: signInMode !== SignInMode.SignIn,
};
},
toRemoteModel: (setup: SignInExperienceForm): SignInExperience => {
const {
branding,
languageInfo: { mode, fallbackLanguage, fixedLanguage },
createAccountEnabled,
} = setup;
const { branding, createAccountEnabled } = setup;
return {
...setup,
@ -84,11 +74,6 @@ export const signInExperienceParser = {
email: findMethodState(setup, 'email'),
social: findMethodState(setup, 'social'),
},
languageInfo: {
autoDetect: mode === LanguageMode.Auto,
fallbackLanguage,
fixedLanguage,
},
signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn,
};
},
@ -115,3 +100,34 @@ export const compareSignInMethods = (
return Object.values(SignInMethodKey).every((key) => beforeMethods[key] === afterMethods[key]);
};
export const flattenTranslation = (
translation: Translation,
keyPrefix = ''
): Record<string, string> =>
Object.keys(translation).reduce((result, key) => {
const prefix = keyPrefix ? `${keyPrefix}.` : keyPrefix;
const unwrappedKey = `${prefix}${key}`;
const unwrapped = translation[key];
return unwrapped === undefined
? result
: {
...result,
...(typeof unwrapped === 'string'
? { [unwrappedKey]: unwrapped }
: flattenTranslation(unwrapped, unwrappedKey)),
};
}, {});
const emptyTranslation = (translation: Translation): Translation =>
Object.entries(translation).reduce((result, [key, value]) => {
return typeof value === 'string'
? { ...result, [key]: '' }
: {
...result,
[key]: emptyTranslation(value),
};
}, {});
export const createEmptyUiTranslation = () => emptyTranslation(en.translation);

View file

@ -0,0 +1,7 @@
import { LanguageTag } from '@logto/language-kit';
import { Translation } from '@logto/schemas';
export type CustomPhraseResponse = {
languageTag: LanguageTag;
translation: Translation;
};

View file

@ -25,11 +25,14 @@
"dependencies": {
"@logto/cli": "^1.0.0-beta.10",
"@logto/connector-kit": "^1.0.0-beta.13",
"@logto/core-kit": "^1.0.0-beta.13",
"@logto/core-kit": "^1.0.0-beta.16",
"@logto/language-kit": "^1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.10",
"@logto/phrases-ui": "^1.0.0-beta.10",
"@logto/schemas": "^1.0.0-beta.10",
"@silverhand/essentials": "^1.2.1",
"chalk": "^4",
"clean-deep": "^3.4.0",
"dayjs": "^1.10.5",
"debug": "^4.3.4",
"decamelize": "^5.0.0",

View file

@ -0,0 +1,66 @@
import en from '@logto/phrases-ui/lib/locales/en';
export const enTag = 'en';
export const trTrTag = 'tr-TR';
export const zhCnTag = 'zh-CN';
export const zhHkTag = 'zh-HK';
export const mockEnCustomPhrase = {
languageTag: enTag,
translation: {
input: {
username: 'Username 1',
password: 'Password 2',
email: 'Email 3',
phone_number: 'Phone number 4',
confirm_password: 'Confirm password 5',
},
},
};
export const mockEnPhrase = {
languageTag: enTag,
translation: {
...en.translation,
...mockEnCustomPhrase.translation,
},
};
export const mockTrTrCustomPhrase = {
languageTag: trTrTag,
translation: {
input: {
username: 'Kullanıcı Adı 1',
password: 'Şifre 2',
email: 'E-posta Adresi 3',
phone_number: 'Telefon Numarası 4',
confirm_password: 'Şifreyi Doğrula 5',
},
},
};
export const mockZhCnCustomPhrase = {
languageTag: zhCnTag,
translation: {
input: {
username: '用户名 1',
password: '密码 2',
email: '邮箱 3',
phone_number: '手机号 4',
confirm_password: '确认密码 5',
},
},
};
export const mockZhHkCustomPhrase = {
languageTag: zhHkTag,
translation: {
input: {
email: '郵箱 1',
password: '密碼 2',
username: '用戶名 3',
phone_number: '手機號 4',
confirm_password: '確認密碼 5',
},
},
};

View file

@ -0,0 +1,112 @@
import resource from '@logto/phrases-ui';
import { CustomPhrase } from '@logto/schemas';
import deepmerge from 'deepmerge';
import {
enTag,
mockEnCustomPhrase,
mockZhCnCustomPhrase,
mockZhHkCustomPhrase,
trTrTag,
zhCnTag,
zhHkTag,
} from '@/__mocks__/custom-phrase';
import RequestError from '@/errors/RequestError';
import { getPhrase } from '@/lib/phrase';
const englishBuiltInPhrase = resource[enTag];
const customOnlyLanguage = zhHkTag;
const customOnlyCustomPhrase = mockZhHkCustomPhrase;
const customizedLanguage = zhCnTag;
const customizedBuiltInPhrase = resource[zhCnTag];
const customizedCustomPhrase = mockZhCnCustomPhrase;
const mockCustomPhrases: Record<string, CustomPhrase> = {
[enTag]: mockEnCustomPhrase,
[customOnlyLanguage]: customOnlyCustomPhrase,
[customizedLanguage]: customizedCustomPhrase,
};
const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
const mockCustomPhrase = mockCustomPhrases[languageTag];
if (!mockCustomPhrase) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
return mockCustomPhrase;
});
jest.mock('@/queries/custom-phrase', () => ({
findCustomPhraseByLanguageTag: async (key: string) => findCustomPhraseByLanguageTag(key),
}));
afterEach(() => {
jest.clearAllMocks();
});
it('should ignore empty string values from the custom phrase', async () => {
const mockTranslationInput = {
email: 'Email 3',
phone_number: 'Phone number 4',
confirm_password: 'Confirm password 5',
};
const mockEnCustomPhraseWithEmptyStringValues = {
languageTag: enTag,
translation: {
input: {
...resource.en.translation.input,
...mockTranslationInput,
username: '',
password: '',
},
},
};
findCustomPhraseByLanguageTag.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues);
await expect(getPhrase(enTag, [enTag])).resolves.toEqual(
deepmerge(englishBuiltInPhrase, {
languageTag: enTag,
translation: {
input: {
...resource.en.translation.input,
...mockTranslationInput,
},
},
})
);
});
describe('when the language is English', () => {
it('should be English custom phrase merged with its built-in phrase when its custom phrase exists', async () => {
await expect(getPhrase(enTag, [enTag])).resolves.toEqual(
deepmerge(englishBuiltInPhrase, mockEnCustomPhrase)
);
});
it('should be English built-in phrase when its custom phrase does not exist', async () => {
await expect(getPhrase(enTag, [])).resolves.toEqual(englishBuiltInPhrase);
});
});
describe('when the language is not English', () => {
it('should be custom phrase merged with built-in phrase when both of them exist', async () => {
await expect(getPhrase(customizedLanguage, [customizedLanguage])).resolves.toEqual(
deepmerge(customizedBuiltInPhrase, customizedCustomPhrase)
);
});
it('should be built-in phrase when there is built-in phrase and no custom phrase', async () => {
const builtInOnlyLanguage = trTrTag;
const builtInOnlyPhrase = resource[trTrTag];
await expect(getPhrase(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase);
});
it('should be built-in phrase when there is custom phrase and no built-in phrase', async () => {
await expect(getPhrase(customOnlyLanguage, [customOnlyLanguage])).resolves.toEqual(
deepmerge(englishBuiltInPhrase, customOnlyCustomPhrase)
);
});
});

View file

@ -0,0 +1,24 @@
import resource, { isBuiltInLanguageTag, LocalePhrase } from '@logto/phrases-ui';
import { CustomPhrase } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import deepmerge from 'deepmerge';
import { findCustomPhraseByLanguageTag } from '@/queries/custom-phrase';
export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => {
if (!isBuiltInLanguageTag(supportedLanguage)) {
return deepmerge<LocalePhrase, CustomPhrase>(
resource.en,
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
);
}
if (!customLanguages.includes(supportedLanguage)) {
return resource[supportedLanguage];
}
return deepmerge<LocalePhrase, CustomPhrase>(
resource[supportedLanguage],
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
);
};

View file

@ -1,3 +1,5 @@
import { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-ui';
import { BrandingStyle, SignInMethodState, ConnectorType } from '@logto/schemas';
import {
@ -11,12 +13,24 @@ import RequestError from '@/errors/RequestError';
import {
isEnabled,
validateBranding,
validateLanguageInfo,
validateSignInMethods,
validateTermsOfUse,
} from '@/lib/sign-in-experience';
const enabledConnectors = [mockFacebookConnector, mockGithubConnector];
const allCustomLanguageTags: LanguageTag[] = [];
const findAllCustomLanguageTags = jest.fn(async () => allCustomLanguageTags);
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe('validate branding', () => {
test('should throw when the UI style contains the slogan and slogan is empty', () => {
expect(() => {
@ -59,6 +73,61 @@ describe('validate branding', () => {
});
});
describe('validate language info', () => {
it('should call findAllCustomLanguageTags', async () => {
await validateLanguageInfo({
autoDetect: true,
fallbackLanguage: 'zh-CN',
fixedLanguage: 'en',
});
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
});
it('should pass when the language is built-in supported', async () => {
const builtInSupportedLanguage = 'tr-TR';
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: builtInSupportedLanguage,
fixedLanguage: 'en',
})
).resolves.not.toThrow();
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
});
it('should pass when the language is custom supported', async () => {
const customOnlySupportedLanguage = 'zh-HK';
expect(customOnlySupportedLanguage in builtInLanguages).toBeFalsy();
findAllCustomLanguageTags.mockResolvedValueOnce([customOnlySupportedLanguage]);
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: customOnlySupportedLanguage,
fixedLanguage: 'en',
})
).resolves.not.toThrow();
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
});
it('unsupported fallback language should fail', async () => {
const unsupportedLanguage = 'zh-MO';
expect(unsupportedLanguage in builtInLanguages).toBeFalsy();
expect(allCustomLanguageTags.includes(unsupportedLanguage)).toBeFalsy();
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: unsupportedLanguage,
fixedLanguage: 'en',
})
).rejects.toMatchError(
new RequestError({
code: 'sign_in_experiences.unsupported_default_language',
language: unsupportedLanguage,
})
);
});
});
describe('validate terms of use', () => {
test('should throw when terms of use is enabled and content URL is empty', () => {
expect(() => {

View file

@ -1,6 +1,8 @@
import { builtInLanguages } from '@logto/phrases-ui';
import {
Branding,
BrandingStyle,
LanguageInfo,
SignInMethods,
SignInMethodState,
TermsOfUse,
@ -9,6 +11,7 @@ import { Optional } from '@silverhand/essentials';
import { ConnectorType, LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { findAllCustomLanguageTags } from '@/queries/custom-phrase';
import assertThat from '@/utils/assert-that';
export const validateBranding = (branding: Branding) => {
@ -19,6 +22,18 @@ export const validateBranding = (branding: Branding) => {
assertThat(branding.logoUrl.trim(), 'sign_in_experiences.empty_logo');
};
export const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
const supportedLanguages = [...builtInLanguages, ...(await findAllCustomLanguageTags())];
assertThat(
supportedLanguages.includes(languageInfo.fallbackLanguage),
new RequestError({
code: 'sign_in_experiences.unsupported_default_language',
language: languageInfo.fallbackLanguage,
})
);
};
export const validateTermsOfUse = (termsOfUse: TermsOfUse) => {
assertThat(
!termsOfUse.enabled || termsOfUse.contentUrl,

View file

@ -4,6 +4,7 @@ import koaBody from 'koa-body';
import { IMiddleware, IRouterParamContext } from 'koa-router';
import { ZodType } from 'zod';
import envSet from '@/env-set';
import RequestError from '@/errors/RequestError';
import ServerError from '@/errors/ServerError';
import assertThat from '@/utils/assert-that';
@ -114,7 +115,14 @@ export default function koaGuard<
}
if (response !== undefined) {
assertThat(response.safeParse(ctx.body).success, new ServerError());
const result = response.safeParse(ctx.body);
if (!result.success) {
if (!envSet.values.isProduction) {
console.error('Invalid response:', result.error);
}
throw new ServerError();
}
}
};

View file

@ -8,37 +8,49 @@ import { DeletionError } from '@/errors/SlonikError';
const { table, fields } = convertToIdentifiers(CustomPhrases);
export const findAllCustomLanguageTags = async () => {
const rows = await manyRows<{ languageTag: string }>(
envSet.pool.query(sql`
select ${fields.languageTag}
from ${table}
order by ${fields.languageTag}
`)
);
return rows.map((row) => row.languageTag);
};
export const findAllCustomPhrases = async () =>
manyRows(
envSet.pool.query<CustomPhrase>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
order by ${fields.languageKey}
order by ${fields.languageTag}
`)
);
export const findCustomPhraseByLanguageKey = async (languageKey: string): Promise<CustomPhrase> =>
export const findCustomPhraseByLanguageTag = async (languageTag: string): Promise<CustomPhrase> =>
envSet.pool.one<CustomPhrase>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.languageKey} = ${languageKey}
where ${fields.languageTag} = ${languageTag}
`);
export const upsertCustomPhrase = buildInsertInto<CreateCustomPhrase, CustomPhrase>(CustomPhrases, {
returning: true,
onConflict: {
fields: [fields.languageKey],
fields: [fields.languageTag],
setExcludedFields: [fields.translation],
},
});
export const deleteCustomPhraseByLanguageKey = async (languageKey: string) => {
export const deleteCustomPhraseByLanguageTag = async (languageTag: string) => {
const { rowCount } = await envSet.pool.query(sql`
delete from ${table}
where ${fields.languageKey}=${languageKey}
where ${fields.languageTag}=${languageTag}
`);
if (rowCount < 1) {
throw new DeletionError(CustomPhrases.table, languageKey);
throw new DeletionError(CustomPhrases.table, languageTag);
}
};

View file

@ -1,35 +1,26 @@
import { CustomPhrase } from '@logto/schemas';
import en from '@logto/phrases-ui/lib/locales/en';
import { CustomPhrase, SignInExperience, Translation } from '@logto/schemas';
import { mockSignInExperience } from '@/__mocks__';
import { mockZhCnCustomPhrase, trTrTag, zhCnTag } from '@/__mocks__/custom-phrase';
import RequestError from '@/errors/RequestError';
import customPhraseRoutes from '@/routes/custom-phrase';
import { createRequester } from '@/utils/test-utils';
const mockLanguageKey = 'en-US';
const mockLanguageTag = zhCnTag;
const mockPhrase = mockZhCnCustomPhrase;
const mockCustomPhrases: Record<string, CustomPhrase> = {
[mockLanguageKey]: {
languageKey: mockLanguageKey,
translation: {
input: {
username: 'Username',
password: 'Password',
email: 'Email',
phone_number: 'Phone number',
confirm_password: 'Confirm password',
},
},
},
[mockLanguageTag]: mockPhrase,
};
const deleteCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
if (!mockCustomPhrases[languageKey]) {
const deleteCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
if (!mockCustomPhrases[languageTag]) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
});
const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
const mockCustomPhrase = mockCustomPhrases[languageKey];
const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
const mockCustomPhrase = mockCustomPhrases[languageTag];
if (!mockCustomPhrase) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
@ -40,16 +31,36 @@ const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
const findAllCustomPhrases = jest.fn(async (): Promise<CustomPhrase[]> => []);
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => customPhrase);
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => mockPhrase);
jest.mock('@/queries/custom-phrase', () => ({
deleteCustomPhraseByLanguageKey: async (key: string) => deleteCustomPhraseByLanguageKey(key),
deleteCustomPhraseByLanguageTag: async (tag: string) => deleteCustomPhraseByLanguageTag(tag),
findAllCustomPhrases: async () => findAllCustomPhrases(),
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
}));
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
const isValidStructure = jest.fn(
(fullTranslation: Translation, partialTranslation: Partial<Translation>) => true
);
jest.mock('@/utils/translation', () => ({
isValidStructure: (fullTranslation: Translation, partialTranslation: Translation) =>
isValidStructure(fullTranslation, partialTranslation),
}));
const mockFallbackLanguage = trTrTag;
const findDefaultSignInExperience = jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: mockFallbackLanguage,
fixedLanguage: mockFallbackLanguage,
},
})
);
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
@ -70,7 +81,7 @@ describe('customPhraseRoutes', () => {
it('should return all custom phrases', async () => {
const mockCustomPhrase = {
languageKey: 'zh-HK',
languageTag: 'zh-HK',
translation: {
input: { username: '用戶名', password: '密碼' },
},
@ -82,59 +93,103 @@ describe('customPhraseRoutes', () => {
});
});
describe('GET /custom-phrases/:languageKey', () => {
it('should call findCustomPhraseByLanguageKey once', async () => {
await customPhraseRequest.get(`/custom-phrases/${mockLanguageKey}`);
expect(findCustomPhraseByLanguageKey).toBeCalledTimes(1);
describe('GET /custom-phrases/:languageTag', () => {
it('should call findCustomPhraseByLanguageTag once', async () => {
await customPhraseRequest.get(`/custom-phrases/${mockLanguageTag}`);
expect(findCustomPhraseByLanguageTag).toBeCalledTimes(1);
});
it('should return the specified custom phrase existing in the database', async () => {
const response = await customPhraseRequest.get(`/custom-phrases/${mockLanguageKey}`);
const response = await customPhraseRequest.get(`/custom-phrases/${mockLanguageTag}`);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
expect(response.body).toEqual(mockCustomPhrases[mockLanguageTag]);
});
it('should return 404 status code when there is no specified custom phrase in the database', async () => {
const response = await customPhraseRequest.get('/custom-phrases/en-UK');
const response = await customPhraseRequest.get('/custom-phrases/en-GB');
expect(response.status).toEqual(404);
});
it('should return 400 status code when the language tag is invalid', async () => {
const invalidLanguageTag = 'xx-XX';
const response = await customPhraseRequest.get(`/custom-phrases/${invalidLanguageTag}`);
expect(response.status).toEqual(400);
});
});
describe('PUT /custom-phrases/:languageKey', () => {
it('should call upsertCustomPhrase with specified language key', async () => {
await customPhraseRequest
.put(`/custom-phrases/${mockLanguageKey}`)
.send(mockCustomPhrases[mockLanguageKey]?.translation);
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageKey]);
describe('PUT /custom-phrases/:languageTag', () => {
const translation = mockCustomPhrases[mockLanguageTag]?.translation;
it('should remove empty strings', async () => {
const inputTranslation = { username: '用户名 1' };
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send({
input: { ...inputTranslation, password: '' },
});
expect(upsertCustomPhrase).toBeCalledWith({
languageTag: mockLanguageTag,
translation: { input: inputTranslation },
});
});
it('should return the custom phrase after upserting', async () => {
it('should call isValidStructure', async () => {
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send(translation);
expect(isValidStructure).toBeCalledWith(en.translation, translation);
});
it('should fail when the input translation structure is invalid', async () => {
isValidStructure.mockReturnValueOnce(false);
const response = await customPhraseRequest
.put(`/custom-phrases/${mockLanguageKey}`)
.send(mockCustomPhrases[mockLanguageKey]?.translation);
.put(`/custom-phrases/${mockLanguageTag}`)
.send(translation);
expect(response.status).toEqual(400);
});
it('should call upsertCustomPhrase with specified language tag', async () => {
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send(translation);
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageTag]);
});
it('should return custom phrase after upserting', async () => {
const response = await customPhraseRequest
.put(`/custom-phrases/${mockLanguageTag}`)
.send(translation);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
expect(response.body).toEqual(mockCustomPhrases[mockLanguageTag]);
});
it('should return 400 status code when the language tag is invalid', async () => {
const invalidLanguageTag = 'xx-XX';
const response = await customPhraseRequest
.put(`/custom-phrases/${invalidLanguageTag}`)
.send(mockCustomPhrases[mockLanguageTag]?.translation);
expect(response.status).toEqual(400);
});
});
describe('DELETE /custom-phrases/:languageKey', () => {
it('should call deleteCustomPhraseByLanguageKey', async () => {
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
expect(deleteCustomPhraseByLanguageKey).toBeCalledWith(mockLanguageKey);
describe('DELETE /custom-phrases/:languageTag', () => {
it('should call deleteCustomPhraseByLanguageTag when custom phrase exists and is not fallback language in sign-in experience', async () => {
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageTag}`);
expect(deleteCustomPhraseByLanguageTag).toBeCalledWith(mockLanguageTag);
});
it('should return 204 status code after deleting the specified custom phrase', async () => {
const response = await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
it('should return 204 status code after deleting specified custom phrase', async () => {
const response = await customPhraseRequest.delete(`/custom-phrases/${mockLanguageTag}`);
expect(response.status).toEqual(204);
});
it('should return 404 status code when the specified custom phrase does not exist before deleting', async () => {
const response = await customPhraseRequest.delete('/custom-phrases/en-UK');
it('should return 404 status code when specified custom phrase does not exist before deleting', async () => {
const response = await customPhraseRequest.delete('/custom-phrases/en-GB');
expect(response.status).toEqual(404);
});
it('should return 400 status code when the specified custom phrase is used as default language in sign-in experience', async () => {
const response = await customPhraseRequest.delete('/custom-phrases/en');
it('should return 400 status code when specified custom phrase is used as fallback language in sign-in experience', async () => {
const response = await customPhraseRequest.delete(`/custom-phrases/${mockFallbackLanguage}`);
expect(response.status).toEqual(400);
});
it('should return 400 status code when the language tag is invalid', async () => {
const invalidLanguageTag = 'xx-XX';
const response = await customPhraseRequest.delete(`/custom-phrases/${invalidLanguageTag}`);
expect(response.status).toEqual(400);
});
});

View file

@ -1,17 +1,28 @@
import { CustomPhrases, translationGuard } from '@logto/schemas';
import { languageTagGuard } from '@logto/language-kit';
import resource from '@logto/phrases-ui';
import { CustomPhrases, Translation, translationGuard } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import { object } from 'zod';
import RequestError from '@/errors/RequestError';
import koaGuard from '@/middleware/koa-guard';
import {
deleteCustomPhraseByLanguageKey,
deleteCustomPhraseByLanguageTag,
findAllCustomPhrases,
findCustomPhraseByLanguageKey,
findCustomPhraseByLanguageTag,
upsertCustomPhrase,
} from '@/queries/custom-phrase';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import assertThat from '@/utils/assert-that';
import { isValidStructure } from '@/utils/translation';
import { AuthedRouter } from './types';
const cleanDeepTranslation = (translation: Translation) =>
// Since `Translation` type actually equals `Partial<Translation>`, force to cast it back to `Translation`.
// eslint-disable-next-line no-restricted-syntax
cleanDeep(translation) as Translation;
export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
router.get(
'/custom-phrases',
@ -26,64 +37,70 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
);
router.get(
'/custom-phrases/:languageKey',
'/custom-phrases/:languageTag',
koaGuard({
// Next up: guard languageKey by enum LanguageKey (that will be provided by @sijie later.)
params: CustomPhrases.createGuard.pick({ languageKey: true }),
params: object({ languageTag: languageTagGuard }),
response: CustomPhrases.guard,
}),
async (ctx, next) => {
const {
params: { languageKey },
params: { languageTag },
} = ctx.guard;
ctx.body = await findCustomPhraseByLanguageKey(languageKey);
ctx.body = await findCustomPhraseByLanguageTag(languageTag);
return next();
}
);
router.put(
'/custom-phrases/:languageKey',
'/custom-phrases/:languageTag',
koaGuard({
params: CustomPhrases.createGuard.pick({ languageKey: true }),
params: object({ languageTag: languageTagGuard }),
body: translationGuard,
response: CustomPhrases.guard,
}),
async (ctx, next) => {
const {
params: { languageKey },
params: { languageTag },
body,
} = ctx.guard;
ctx.body = await upsertCustomPhrase({ languageKey, translation: body });
const translation = cleanDeepTranslation(body);
assertThat(
isValidStructure(resource.en.translation, translation),
new RequestError('localization.invalid_translation_structure')
);
ctx.body = await upsertCustomPhrase({ languageTag, translation });
return next();
}
);
router.delete(
'/custom-phrases/:languageKey',
'/custom-phrases/:languageTag',
koaGuard({
params: CustomPhrases.createGuard.pick({ languageKey: true }),
params: object({ languageTag: languageTagGuard }),
}),
async (ctx, next) => {
const {
params: { languageKey },
params: { languageTag },
} = ctx.guard;
const {
languageInfo: { fallbackLanguage },
} = await findDefaultSignInExperience();
if (fallbackLanguage === languageKey) {
if (fallbackLanguage === languageTag) {
throw new RequestError({
code: 'localization.cannot_delete_default_language',
languageKey,
languageTag,
});
}
await deleteCustomPhraseByLanguageKey(languageKey);
await deleteCustomPhraseByLanguageTag(languageTag);
ctx.status = 204;
return next();

View file

@ -13,6 +13,7 @@ import connectorRoutes from '@/routes/connector';
import customPhraseRoutes from '@/routes/custom-phrase';
import dashboardRoutes from '@/routes/dashboard';
import logRoutes from '@/routes/log';
import phraseRoutes from '@/routes/phrase';
import resourceRoutes from '@/routes/resource';
import roleRoutes from '@/routes/role';
import sessionRoutes from '@/routes/session';
@ -43,6 +44,7 @@ const createRouters = (provider: Provider) => {
customPhraseRoutes(managementRouter);
const anonymousRouter: AnonymousRouter = new Router();
phraseRoutes(anonymousRouter, provider);
wellKnownRoutes(anonymousRouter, provider);
statusRoutes(anonymousRouter);
authnRoutes(anonymousRouter);

View file

@ -0,0 +1,152 @@
import en from '@logto/phrases-ui/lib/locales/en';
import { Provider } from 'oidc-provider';
import { mockSignInExperience } from '@/__mocks__';
import { trTrTag, zhCnTag, zhHkTag } from '@/__mocks__/custom-phrase';
import phraseRoutes from '@/routes/phrase';
import { createRequester } from '@/utils/test-utils';
const mockApplicationId = 'mockApplicationIdValue';
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
params: { client_id: mockApplicationId },
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
const fallbackLanguage = trTrTag;
const unsupportedLanguageX = 'xx-XX';
const unsupportedLanguageY = 'yy-YY';
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage,
fixedLanguage: fallbackLanguage,
},
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
}));
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageTags: async () => [trTrTag, zhCnTag],
}));
jest.mock('@/lib/phrase', () => ({
...jest.requireActual('@/lib/phrase'),
getPhrase: jest.fn().mockResolvedValue(en),
}));
const phraseRequest = createRequester({
anonymousRoutes: phraseRoutes,
provider: new Provider(''),
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when auto-detect is not enabled', () => {
it('should be English when fallback language is unsupported', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: false,
fallbackLanguage: unsupportedLanguageX,
fixedLanguage: unsupportedLanguageX,
},
});
const response = await phraseRequest
.get('/phrase')
.set('Accept-Language', `${zhCnTag},${zhHkTag}`);
expect(response.headers['content-language']).toEqual('en');
});
describe('should be fallback language', () => {
beforeEach(() => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: false,
fallbackLanguage,
fixedLanguage: fallbackLanguage,
},
});
});
it('when there is no detected language', async () => {
const response = await phraseRequest.get('/phrase');
expect(response.headers['content-language']).toEqual(fallbackLanguage);
});
it('when there are detected languages', async () => {
const response = await phraseRequest
.get('/phrase')
.set('Accept-Language', `${zhCnTag},${zhHkTag}`);
expect(response.headers['content-language']).toEqual(fallbackLanguage);
});
});
});
describe('when auto-detect is enabled', () => {
it('should be English when fallback language is unsupported', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: unsupportedLanguageX,
fixedLanguage: unsupportedLanguageX,
},
});
const response = await phraseRequest
.get('/phrase')
.set('Accept-Language', unsupportedLanguageY);
expect(response.headers['content-language']).toEqual('en');
});
describe('when fallback language is supported', () => {
beforeEach(() => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage,
fixedLanguage: fallbackLanguage,
},
});
});
describe('when there is no detected language', () => {
it('should be fallback language from sign-in experience', async () => {
const response = await phraseRequest.get('/phrase');
expect(response.headers['content-language']).toEqual(fallbackLanguage);
});
});
describe('when there are detected languages but all of them is unsupported', () => {
it('should be first supported detected language', async () => {
const response = await phraseRequest
.get('/phrase')
.set('Accept-Language', `${unsupportedLanguageX},${unsupportedLanguageY}`);
expect(response.headers['content-language']).toEqual(fallbackLanguage);
});
});
describe('when there are detected languages but some of them is unsupported', () => {
it('should be first supported detected language', async () => {
const firstSupportedLanguage = zhCnTag;
const response = await phraseRequest
.get('/phrase')
.set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkTag}`);
expect(response.headers['content-language']).toEqual(firstSupportedLanguage);
});
});
});
});

View file

@ -0,0 +1,157 @@
import zhCN from '@logto/phrases-ui/lib/locales/zh-cn';
import { SignInExperience } from '@logto/schemas';
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds';
import { Provider } from 'oidc-provider';
import { mockSignInExperience } from '@/__mocks__';
import { zhCnTag } from '@/__mocks__/custom-phrase';
import * as detectLanguage from '@/i18n/detect-language';
import phraseRoutes from '@/routes/phrase';
import { createRequester } from '@/utils/test-utils';
const mockApplicationId = 'mockApplicationIdValue';
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
params: { client_id: mockApplicationId },
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
const customizedLanguage = zhCnTag;
const findDefaultSignInExperience = jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: customizedLanguage,
fixedLanguage: customizedLanguage,
},
})
);
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
}));
const detectLanguageSpy = jest.spyOn(detectLanguage, 'default');
const findAllCustomLanguageTags = jest.fn(async () => [customizedLanguage]);
const findCustomPhraseByLanguageTag = jest.fn(async (tag: string) => ({}));
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
}));
const getPhrase = jest.fn(async (language: string, customLanguages: string[]) => zhCN);
jest.mock('@/lib/phrase', () => ({
...jest.requireActual('@/lib/phrase'),
getPhrase: async (language: string, customLanguages: string[]) =>
getPhrase(language, customLanguages),
}));
const phraseRequest = createRequester({
anonymousRoutes: phraseRoutes,
provider: new Provider(''),
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when the application is admin-console', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
params: { client_id: adminConsoleApplicationId },
});
});
it('should call interactionDetails', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(interactionDetails).toBeCalledTimes(1);
});
it('should not call findDefaultSignInExperience', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(findDefaultSignInExperience).not.toBeCalled();
});
it('should call detectLanguage', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(detectLanguageSpy).toBeCalledTimes(1);
});
it('should call findAllCustomLanguageTags', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
});
it('should call getPhrase with fallback language from Admin Console sign-in experience', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(getPhrase).toBeCalledTimes(1);
expect(getPhrase).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [
customizedLanguage,
]);
});
});
describe('when the application is not admin-console', () => {
it('should call interactionDetails', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(interactionDetails).toBeCalledTimes(1);
});
it('should call findDefaultSignInExperience', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(findDefaultSignInExperience).toBeCalledTimes(1);
});
it('should call detectLanguage when auto-detect is enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
...mockSignInExperience.languageInfo,
autoDetect: true,
},
});
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(detectLanguageSpy).toBeCalledTimes(1);
});
it('should not call detectLanguage when auto-detect is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
...mockSignInExperience.languageInfo,
autoDetect: false,
},
});
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(detectLanguageSpy).not.toBeCalled();
});
it('should call findAllCustomLanguageTags', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
});
it('should call getPhrase with fallback language from default sign-in experience', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
languageInfo: {
autoDetect: false,
fallbackLanguage: customizedLanguage,
fixedLanguage: customizedLanguage,
},
});
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(getPhrase).toBeCalledTimes(1);
expect(getPhrase).toBeCalledWith(customizedLanguage, [customizedLanguage]);
});
});

View file

@ -0,0 +1,45 @@
import { isBuiltInLanguageTag } from '@logto/phrases-ui';
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds';
import { Provider } from 'oidc-provider';
import detectLanguage from '@/i18n/detect-language';
import { getPhrase } from '@/lib/phrase';
import { findAllCustomLanguageTags } from '@/queries/custom-phrase';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { AnonymousRouter } from './types';
const getLanguageInfo = async (applicationId: unknown) => {
if (applicationId === adminConsoleApplicationId) {
return adminConsoleSignInExperience.languageInfo;
}
const { languageInfo } = await findDefaultSignInExperience();
return languageInfo;
};
export default function phraseRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
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);
const applicationId = interaction?.params.client_id;
const { autoDetect, fallbackLanguage } = await getLanguageInfo(applicationId);
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
const acceptableLanguages = [...detectedLanguages, fallbackLanguage];
const customLanguages = await findAllCustomLanguageTags();
const language =
acceptableLanguages.find(
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
) ?? 'en';
ctx.set('Content-Language', language);
ctx.body = await getPhrase(language, customLanguages);
return next();
});
}

View file

@ -1,5 +1,9 @@
import { languageKeys } from '@logto/core-kit';
import { CreateSignInExperience, SignInExperience, SignInMethodState } from '@logto/schemas';
import {
CreateSignInExperience,
LanguageInfo,
SignInExperience,
SignInMethodState,
} from '@logto/schemas';
import {
mockAliyunDmConnector,
@ -26,6 +30,14 @@ jest.mock('@/connectors', () => ({
]),
}));
// eslint-disable-next-line @typescript-eslint/no-empty-function
const validateLanguageInfo = jest.fn(async (languageInfo: LanguageInfo): Promise<void> => {});
jest.mock('@/lib/sign-in-experience', () => ({
...jest.requireActual('@/lib/sign-in-experience'),
validateLanguageInfo: async (languageInfo: LanguageInfo) => validateLanguageInfo(languageInfo),
}));
jest.mock('@/queries/sign-in-experience', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
@ -48,6 +60,10 @@ const expectPatchResponseStatus = async (
const validBooleans = [true, false];
const invalidBooleans = [undefined, null, 0, 1, '0', '1', 'true', 'false'];
beforeEach(() => {
jest.clearAllMocks();
});
describe('terms of use', () => {
describe('enabled', () => {
test.each(validBooleans)('%p should success', async (enabled) => {
@ -104,8 +120,8 @@ describe('languageInfo', () => {
});
});
const validLanguages = languageKeys;
const invalidLanguages = [undefined, null, '', ' \t\n\r', 'abc'];
const validLanguages = ['en', 'pt-PT', 'zh-HK', 'zh-TW'];
const invalidLanguages = [undefined, null, '', ' \t\n\r', 'ab', 'xx-XX'];
describe('fallbackLanguage', () => {
test.each(validLanguages)('%p should success', async (fallbackLanguage) => {
@ -119,16 +135,10 @@ describe('languageInfo', () => {
});
});
describe('fixedLanguage', () => {
test.each(validLanguages)('%p should success', async (fixedLanguage) => {
const signInExperience = { languageInfo: { ...mockLanguageInfo, fixedLanguage } };
await expectPatchResponseStatus(signInExperience, 200);
});
test.each(invalidLanguages)('%p should fail', async (fixedLanguage) => {
const signInExperience = { languageInfo: { ...mockLanguageInfo, fixedLanguage } };
await expectPatchResponseStatus(signInExperience, 400);
});
it('should call validateLanguageInfo', async () => {
const signInExperience = { languageInfo: mockLanguageInfo };
await expectPatchResponseStatus(signInExperience, 200);
expect(validateLanguageInfo).toBeCalledWith(mockLanguageInfo);
});
});

View file

@ -14,6 +14,7 @@ import {
mockSignInMethods,
mockWechatConnector,
mockColor,
mockLanguageInfo,
} from '@/__mocks__';
import * as signInExpLib from '@/lib/sign-in-experience';
import { createRequester } from '@/utils/test-utils';
@ -50,6 +51,10 @@ jest.mock('@/queries/sign-in-experience', () => ({
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageTags: async () => [],
}));
describe('GET /sign-in-exp', () => {
afterAll(() => {
jest.clearAllMocks();
@ -138,18 +143,21 @@ describe('PATCH /sign-in-exp', () => {
const socialSignInConnectorTargets = ['github', 'facebook', 'wechat'];
const validateBranding = jest.spyOn(signInExpLib, 'validateBranding');
const validateLanguageInfo = jest.spyOn(signInExpLib, 'validateLanguageInfo');
const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse');
const validateSignInMethods = jest.spyOn(signInExpLib, 'validateSignInMethods');
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
color: mockColor,
branding: mockBranding,
languageInfo: mockLanguageInfo,
termsOfUse,
signInMethods: mockSignInMethods,
socialSignInConnectorTargets,
});
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo);
expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse);
expect(validateSignInMethods).toHaveBeenCalledWith(
mockSignInMethods,

View file

@ -3,6 +3,7 @@ import { ConnectorType, SignInExperiences } from '@logto/schemas';
import { getLogtoConnectors } from '@/connectors';
import {
validateBranding,
validateLanguageInfo,
validateTermsOfUse,
validateSignInMethods,
isEnabled,
@ -33,12 +34,16 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
}),
async (ctx, next) => {
const { socialSignInConnectorTargets, ...rest } = ctx.guard.body;
const { branding, termsOfUse, signInMethods } = rest;
const { branding, languageInfo, termsOfUse, signInMethods } = rest;
if (branding) {
validateBranding(branding);
}
if (languageInfo) {
await validateLanguageInfo(languageInfo);
}
if (termsOfUse) {
validateTermsOfUse(termsOfUse);
}

View file

@ -0,0 +1,42 @@
import en from '@logto/phrases-ui/lib/locales/en';
import fr from '@logto/phrases-ui/lib/locales/fr';
import { isValidStructure } from '@/utils/translation';
const customizedFrTranslation = {
secondary: {
sign_in_with: 'Customized value A',
social_bind_with: 'Customized value B',
},
};
describe('isValidStructure', () => {
it('should be true when its structure is valid', () => {
expect(isValidStructure(en.translation, fr.translation)).toBeTruthy();
expect(isValidStructure(en.translation, customizedFrTranslation)).toBeTruthy();
});
it('should be true when the structure is partial and the existing key-value pairs are correct', () => {
expect(
isValidStructure(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
// Missing 'secondary.social_bind_with' key-value pair
},
})
).toBeTruthy();
});
it('should be false when there is an unexpected key-value pair', () => {
expect(
isValidStructure(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
social_bind_with:
'Vous avez déjà un compte ? Connectez-vous pour lier {{methods, list(type: disjunction;)}} avec votre identité sociale.',
foo: 'bar', // Unexpected key-value pair
},
})
).toBeFalsy();
});
});

View file

@ -0,0 +1,36 @@
import { Translation } from '@logto/schemas';
export const isValidStructure = (fullTranslation: Translation, partialTranslation: Translation) => {
const fullKeys = new Set(Object.keys(fullTranslation));
const partialKeys = Object.keys(partialTranslation);
if (fullKeys.size === 0 || partialKeys.length === 0) {
return true;
}
if (partialKeys.some((key) => !fullKeys.has(key))) {
return false;
}
for (const [key, value] of Object.entries(fullTranslation)) {
const targetValue = partialTranslation[key];
if (targetValue === undefined) {
continue;
}
if (typeof value === 'string') {
if (typeof targetValue === 'string') {
continue;
}
return false;
}
if (typeof targetValue === 'string' || !isValidStructure(value, targetValue)) {
return false;
}
}
return true;
};

View file

@ -1,3 +1,4 @@
import { languages, languageTagGuard } from '@logto/language-kit';
import { ApplicationType, arbitraryObjectGuard, translationGuard } from '@logto/schemas';
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
@ -19,6 +20,13 @@ describe('zodTypeToSwagger', () => {
});
});
it('language tag guard', () => {
expect(zodTypeToSwagger(languageTagGuard)).toEqual({
type: 'string',
enum: Object.keys(languages),
});
});
describe('string type', () => {
const notStartingWithDigitRegex = /^\D/;

View file

@ -1,3 +1,4 @@
import { languages, languageTagGuard } from '@logto/language-kit';
import { arbitraryObjectGuard, translationGuard } from '@logto/schemas';
import { conditional, ValuesOf } from '@silverhand/essentials';
import { OpenAPIV3 } from 'openapi-types';
@ -140,6 +141,13 @@ export const zodTypeToSwagger = (
};
}
if (config === languageTagGuard) {
return {
type: 'string',
enum: Object.keys(languages),
};
}
if (config instanceof ZodOptional) {
return zodTypeToSwagger(config._def.innerType);
}

View file

@ -18,6 +18,7 @@
},
"devDependencies": {
"@logto/core-kit": "^1.0.0-beta.13",
"@logto/language-kit": "1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.10",
"@logto/react": "1.0.0-beta.8",
"@logto/schemas": "^1.0.0-beta.10",

View file

@ -1,10 +1,10 @@
import type { LanguageKey } from '@logto/core-kit';
import type { LanguageTag } from '@logto/language-kit';
import resources from '@logto/phrases';
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
const initI18n = async (language?: LanguageKey) =>
const initI18n = async (language?: LanguageTag) =>
i18next
.use(initReactI18next)
.use(LanguageDetector)

View file

@ -1,15 +1,12 @@
// https://react.i18next.com/latest/typescript#create-a-declaration-file
import { Translation, Errors } from '@logto/phrases';
import { LocalPhrase } from '@logto/phrases';
// eslint-disable-next-line unused-imports/no-unused-imports
import { CustomTypeOptions } from 'react-i18next';
declare module 'react-i18next' {
interface CustomTypeOptions {
allowObjectInHTMLChildren: true;
resources: {
translation: Translation;
errors: Errors;
};
resources: LocalPhrase;
}
}

View file

@ -29,8 +29,10 @@
"url": "https://github.com/logto-io/logto/issues"
},
"dependencies": {
"@logto/core-kit": "^1.0.0-beta.13",
"@silverhand/essentials": "^1.2.1"
"@logto/core-kit": "1.0.0-beta.16",
"@logto/language-kit": "1.0.0-beta.16",
"@silverhand/essentials": "^1.2.1",
"zod": "^3.18.0"
},
"devDependencies": {
"@silverhand/eslint-config": "1.0.0",

View file

@ -1,4 +1,7 @@
import { fallback } from '@logto/core-kit';
import { languages, LanguageTag } from '@logto/language-kit';
import { NormalizeKeyPaths } from '@silverhand/essentials';
import { z } from 'zod';
import en from './locales/en';
import fr from './locales/fr';
@ -6,12 +9,25 @@ import koKR from './locales/ko-kr';
import ptPT from './locales/pt-pt';
import trTR from './locales/tr-tr';
import zhCN from './locales/zh-cn';
import { Resource } from './types';
import { LocalePhrase } from './types';
export { languageOptions } from './types';
export type { LocalePhrase } from './types';
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export const builtInLanguages = ['en', 'fr', 'pt-PT', 'zh-CN', 'ko-KR', 'tr-TR'] as const;
export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({
value: languageTag,
title: languages[languageTag],
}));
export const builtInLanguageTagGuard = z.enum(builtInLanguages);
export type BuiltInLanguageTag = z.infer<typeof builtInLanguageTagGuard>;
export type Resource = Record<BuiltInLanguageTag, LocalePhrase>;
const resource: Resource = {
en,
fr,
@ -21,4 +37,10 @@ const resource: Resource = {
'tr-TR': trTR,
};
export const getDefaultLanguageTag = (language: string): LanguageTag =>
builtInLanguageTagGuard.or(fallback<LanguageTag>('en')).parse(language);
export const isBuiltInLanguageTag = (language: string): language is BuiltInLanguageTag =>
builtInLanguageTagGuard.safeParse(language).success;
export default resource;

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -85,7 +85,7 @@ const translation = {
},
};
const fr: typeof en = Object.freeze({
const fr: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -78,7 +78,7 @@ const translation = {
},
};
const koKR: typeof en = Object.freeze({
const koKR: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -79,7 +79,7 @@ const translation = {
},
};
const ptPT: typeof en = Object.freeze({
const ptPT: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -79,7 +79,7 @@ const translation = {
},
};
const trTR: typeof en = Object.freeze({
const trTR: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -78,7 +78,7 @@ const translation = {
},
};
const zhCN: typeof en = Object.freeze({
const zhCN: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,21 +1,3 @@
import { LanguageKey, languageKeyGuard } from '@logto/core-kit';
import en from './locales/en';
/* Copied from i18next/index.d.ts */
export type Resource = Record<LanguageKey, ResourceLanguage>;
export type ResourceLanguage = Record<string, ResourceKey>;
export type ResourceKey = string | Record<string, unknown>;
const languageCodeAndDisplayNameMappings: Record<LanguageKey, string> = {
en: 'English',
fr: 'Français',
'pt-PT': 'Português',
'zh-CN': '简体中文',
'tr-TR': 'Türkçe',
'ko-KR': '한국어',
};
export const languageOptions = Object.entries(languageCodeAndDisplayNameMappings).map(
([key, value]) => ({ value: languageKeyGuard.parse(key), title: value })
);
export type LocalePhrase = typeof en;

View file

@ -29,8 +29,10 @@
"url": "https://github.com/logto-io/logto/issues"
},
"dependencies": {
"@logto/core-kit": "^1.0.0-beta.13",
"@silverhand/essentials": "^1.2.1"
"@logto/core-kit": "1.0.0-beta.16",
"@logto/language-kit": "1.0.0-beta.15",
"@silverhand/essentials": "^1.2.1",
"zod": "^3.18.0"
},
"devDependencies": {
"@silverhand/eslint-config": "1.0.0",

View file

@ -1,4 +1,7 @@
import { fallback } from '@logto/core-kit';
import { languages, LanguageTag } from '@logto/language-kit';
import { NormalizeKeyPaths } from '@silverhand/essentials';
import { z } from 'zod';
import en from './locales/en';
import fr from './locales/fr';
@ -6,16 +9,37 @@ import koKR from './locales/ko-kr';
import ptPT from './locales/pt-pt';
import trTR from './locales/tr-tr';
import zhCN from './locales/zh-cn';
import { Resource } from './types';
import { LocalPhrase } from './types';
export type { LocalPhrase } from './types';
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export const builtInLanguages = ['en', 'fr', 'pt-PT', 'zh-CN', 'ko-KR', 'tr-TR'] as const;
export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({
value: languageTag,
title: languages[languageTag],
}));
export const builtInLanguageTagGuard = z.enum(builtInLanguages);
export type BuiltInLanguageTag = z.infer<typeof builtInLanguageTagGuard>;
export { languageOptions } from './types';
export type Translation = typeof en.translation;
export type Errors = typeof en.errors;
export type LogtoErrorCode = NormalizeKeyPaths<Errors>;
export type LogtoErrorI18nKey = `errors:${LogtoErrorCode}`;
export type I18nKey = NormalizeKeyPaths<Translation>;
export type AdminConsoleKey = NormalizeKeyPaths<typeof en.translation.admin_console>;
export const getDefaultLanguageTag = (languages: string): LanguageTag =>
builtInLanguageTagGuard.or(fallback<LanguageTag>('en')).parse(languages);
export const isBuiltInLanguageTag = (language: string): language is BuiltInLanguageTag =>
builtInLanguageTagGuard.safeParse(language).success;
export type Resource = Record<BuiltInLanguageTag, LocalPhrase>;
const resource: Resource = {
en,
fr,

View file

@ -105,10 +105,13 @@ const errors = {
enabled_connector_not_found: 'Enabled {{type}} connector not found.',
not_one_and_only_one_primary_sign_in_method:
'There must be one and only one primary sign-in method. Please check your input.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
invalid_translation_structure:
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
},
swagger: {
invalid_zod_type: 'Invalid Zod type. Please check route guard config.',

View file

@ -35,6 +35,7 @@ const general = {
unsaved_changes_warning: 'You have made some changes. Are you sure you want to leave this page?',
leave_page: 'Leave Page',
stay_on_page: 'Stay on Page',
type_to_search: 'Type to search',
};
export default general;

View file

@ -71,13 +71,34 @@ const sign_in_exp = {
},
languages: {
title: 'LANGUAGES',
mode: 'Language mode',
auto: 'Auto',
fixed: 'Fixed',
fallback_language: 'Fallback language',
fallback_language_tip:
'Which language to fall back if Logto finds no proper language phrase-set.',
fixed_language: 'Fixed language',
enable_auto_detect: 'Enable auto detect',
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.",
manage_language: 'Manage language',
default_language: 'Default language',
default_language_description_auto:
'The default language will be used when a text segment is missing translation.',
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.',
},
manage_language: {
title: 'Manage language',
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.',
add_language: 'Add Language',
logto_provided: 'Logto provided',
key: 'Key',
logto_source_language: 'Logto source language',
custom_values: 'Custom values',
clear_all: 'Clear all',
unsaved_description: 'Changes wont be saved if you leave this page without saving.',
deletion_title: 'Do you want to delete the added language?',
deletion_description:
'After deletion, your users wont be able to browse in that language again.',
default_language_deletion_title: 'Default language cant be deleted.',
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ',
got_it: 'Got It',
},
authentication: {
title: 'AUTHENTICATION',

View file

@ -113,10 +113,13 @@ const errors = {
enabled_connector_not_found: 'Le connecteur {{type}} activé est introuvable.',
not_one_and_only_one_primary_sign_in_method:
'Il doit y avoir une et une seule méthode de connexion primaire. Veuillez vérifier votre saisie.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
invalid_translation_structure:
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
},
swagger: {
invalid_zod_type: 'Type Zod non valide. Veuillez vérifier la configuration du garde-route.',

View file

@ -1,8 +1,8 @@
import en from '../en';
import { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const fr: typeof en = Object.freeze({
const fr: LocalPhrase = Object.freeze({
translation,
errors,
});

View file

@ -36,6 +36,7 @@ const general = {
'Vous avez effectué des changements. Êtes-vous sûr de vouloir quitter cette page ?',
leave_page: 'Quittez la page',
stay_on_page: 'Rester sur la page',
type_to_search: 'Type to search', // UNTRANSLATED
};
export default general;

View file

@ -73,13 +73,34 @@ const sign_in_exp = {
},
languages: {
title: 'LANGAGES',
mode: 'Mode langue',
auto: 'Auto',
fixed: 'Fixé',
fallback_language: 'Langage par défaut',
fallback_language_tip:
'La langue de repli si Logto ne trouve pas de jeu de phrases dans la langue appropriée.',
fixed_language: 'Langue fixe',
enable_auto_detect: 'Enable auto detect', // UNTRANSLATED
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.", // UNTRANSLATED
manage_language: 'Manage language', // UNTRANSLATED
default_language: 'Default language', // UNTRANSLATED
default_language_description_auto:
'The default language will be used when a text segment is missing translation.', // UNTRANSLATED
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
unsaved_description: 'Changes wont be saved if you leave this page without saving.', // UNTRANSLATED
deletion_title: 'Do you want to delete the added language?', // UNTRANSLATED
deletion_description:
'After deletion, your users wont be able to browse in that language again.', // UNTRANSLATED
default_language_deletion_title: 'Default language cant be deleted.', // UNTRANSLATED
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ', // UNTRANSLATED
got_it: 'Got It', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',

View file

@ -102,10 +102,13 @@ const errors = {
enabled_connector_not_found: '활성된 {{type}} 연동을 찾을 수 없어요.',
not_one_and_only_one_primary_sign_in_method:
'반드시 하나의 메인 로그인 방법이 설정되어야 해요. 입력된 값을 확인해주세요.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
invalid_translation_structure:
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
},
swagger: {
invalid_zod_type: '유요하지 않은 Zod 종류에요. Route Guard 설정을 확인해주세요.',

View file

@ -1,8 +1,8 @@
import en from '../en';
import { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const koKR: typeof en = Object.freeze({
const koKR: LocalPhrase = Object.freeze({
translation,
errors,
});

View file

@ -35,6 +35,7 @@ const general = {
unsaved_changes_warning: '수정된 내용이 있어요. 정말로 현재 페이지를 벗어날까요?',
leave_page: '페이지 나가기',
stay_on_page: '페이지 유지하기',
type_to_search: 'Type to search', // UNTRANSLATED
};
export default general;

View file

@ -68,12 +68,34 @@ const sign_in_exp = {
},
languages: {
title: '언어',
mode: '언어 모드',
auto: '자동',
fixed: '고정',
fallback_language: '백업 언어',
fallback_language_tip: '적절한 언어를 찾을 수 없을 때 백업 언어를 사용해요.',
fixed_language: '언어 고정',
enable_auto_detect: 'Enable auto detect', // UNTRANSLATED
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.", // UNTRANSLATED
manage_language: 'Manage language', // UNTRANSLATED
default_language: 'Default language', // UNTRANSLATED
default_language_description_auto:
'The default language will be used when a text segment is missing translation.', // UNTRANSLATED
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
unsaved_description: 'Changes wont be saved if you leave this page without saving.', // UNTRANSLATED
deletion_title: 'Do you want to delete the added language?', // UNTRANSLATED
deletion_description:
'After deletion, your users wont be able to browse in that language again.', // UNTRANSLATED
default_language_deletion_title: 'Default language cant be deleted.', // UNTRANSLATED
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ', // UNTRANSLATED
got_it: 'Got It', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',

View file

@ -108,10 +108,13 @@ const errors = {
enabled_connector_not_found: 'Conector {{type}} ativado não encontrado.',
not_one_and_only_one_primary_sign_in_method:
'Deve haver um e apenas um método de login principal. Por favor, verifique sua entrada.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
invalid_translation_structure:
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
},
swagger: {
invalid_zod_type: 'Tipo de Zod inválido. Verifique a configuração do protetor de rota.',

View file

@ -1,8 +1,8 @@
import en from '../en';
import { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const ptPT: typeof en = Object.freeze({
const ptPT: LocalPhrase = Object.freeze({
translation,
errors,
});

View file

@ -35,6 +35,7 @@ const general = {
unsaved_changes_warning: 'Fez algumas alterações. Tem a certeza que deseja sair desta página?',
leave_page: 'Sair da página',
stay_on_page: 'Ficar na página',
type_to_search: 'Type to search', // UNTRANSLATED
};
export default general;

View file

@ -71,12 +71,34 @@ const sign_in_exp = {
},
languages: {
title: 'LÍNGUAS',
mode: 'Modo de idioma',
auto: 'automático',
fixed: 'Fixo',
fallback_language: 'Idioma fallback',
fallback_language_tip: 'Qual idioma usar se o Logto não encontrar o idioma requisitado.',
fixed_language: 'Idioma fixo',
enable_auto_detect: 'Enable auto detect', // UNTRANSLATED
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.", // UNTRANSLATED
manage_language: 'Manage language', // UNTRANSLATED
default_language: 'Default language', // UNTRANSLATED
default_language_description_auto:
'The default language will be used when a text segment is missing translation.', // UNTRANSLATED
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
unsaved_description: 'Changes wont be saved if you leave this page without saving.', // UNTRANSLATED
deletion_title: 'Do you want to delete the added language?', // UNTRANSLATED
deletion_description:
'After deletion, your users wont be able to browse in that language again.', // UNTRANSLATED
default_language_deletion_title: 'Default language cant be deleted.', // UNTRANSLATED
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ', // UNTRANSLATED
got_it: 'Got It', // UNTRANSLATED
},
authentication: {
title: 'AUTENTICAÇÃO',

View file

@ -106,10 +106,13 @@ const errors = {
enabled_connector_not_found: 'Etkin {{type}} bağlayıcı bulunamadı.',
not_one_and_only_one_primary_sign_in_method:
'Yalnızca bir tane birincil oturum açma yöntemi olmalıdır. Lütfen inputu kontrol ediniz.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
invalid_translation_structure:
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
},
swagger: {
invalid_zod_type:

View file

@ -1,8 +1,8 @@
import en from '../en';
import { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const trTR: typeof en = Object.freeze({
const trTR: LocalPhrase = Object.freeze({
translation,
errors,
});

View file

@ -36,6 +36,7 @@ const general = {
'Bazı değişiklikler yaptınız. Bu sayfadan ayrılmak istediğine emin misin?',
leave_page: 'Sayfayı terk et',
stay_on_page: 'Bu sayfada kal',
type_to_search: 'Type to search', // UNTRANSLATED
};
export default general;

View file

@ -72,13 +72,34 @@ const sign_in_exp = {
},
languages: {
title: 'DİLLER',
mode: 'Dil modu',
auto: 'Otomatik',
fixed: 'Sabit',
fallback_language: 'Yedek dil',
fallback_language_tip:
'Logto uygun bir dil ifade kümesi bulamazsa hangi dilden vazgeçilecek?',
fixed_language: 'Sabitlenmiş dil',
enable_auto_detect: 'Enable auto detect', // UNTRANSLATED
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.", // UNTRANSLATED
manage_language: 'Manage language', // UNTRANSLATED
default_language: 'Default language', // UNTRANSLATED
default_language_description_auto:
'The default language will be used when a text segment is missing translation.', // UNTRANSLATED
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
unsaved_description: 'Changes wont be saved if you leave this page without saving.', // UNTRANSLATED
deletion_title: 'Do you want to delete the added language?', // UNTRANSLATED
deletion_description:
'After deletion, your users wont be able to browse in that language again.', // UNTRANSLATED
default_language_deletion_title: 'Default language cant be deleted.', // UNTRANSLATED
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ', // UNTRANSLATED
got_it: 'Got It', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',

View file

@ -98,9 +98,11 @@ const errors = {
empty_social_connectors: '你启用了社交登录的方式。请至少选择一个社交连接器。',
enabled_connector_not_found: '未找到已启用的 {{type}} 连接器',
not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个,请检查你的输入。',
unsupported_default_language: '不支持默认语言 {{language}}。', // UNTRANSLATED
},
localization: {
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageKey}}。', // UNTRANSLATED
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageTag}}。', // UNTRANSLATED
invalid_translation_structure: '无效的 translation 结构。请检查输入的 translation。', // UNTRANSLATED
},
swagger: {
invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',

View file

@ -1,8 +1,8 @@
import en from '../en';
import { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const zhCN: typeof en = Object.freeze({
const zhCN: LocalPhrase = Object.freeze({
translation,
errors,
});

View file

@ -35,6 +35,7 @@ const general = {
unsaved_changes_warning: '还有未保存的变更, 确定要离开吗?',
leave_page: '离开此页',
stay_on_page: '留在此页',
type_to_search: 'Type to search', // UNTRANSLATED
};
export default general;

View file

@ -68,12 +68,34 @@ const sign_in_exp = {
},
languages: {
title: '语言',
mode: '语言模式',
auto: '自动',
fixed: '固定',
fallback_language: '备用语言',
fallback_language_tip: '如果 Logto 找不到合适的语言包,将回退至哪种语言。',
fixed_language: '固定语言',
enable_auto_detect: 'Enable auto detect', // UNTRANSLATED
description:
"Your software detects the user's location and switches to the local language. You can add new locales by translating UI from English to another language.", // UNTRANSLATED
manage_language: 'Manage language', // UNTRANSLATED
default_language: 'Default language', // UNTRANSLATED
default_language_description_auto:
'The default language will be used when a text segment is missing translation.', // UNTRANSLATED
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
unsaved_description: 'Changes wont be saved if you leave this page without saving.', // UNTRANSLATED
deletion_title: 'Do you want to delete the added language?', // UNTRANSLATED
deletion_description:
'After deletion, your users wont be able to browse in that language again.', // UNTRANSLATED
default_language_deletion_title: 'Default language cant be deleted.', // UNTRANSLATED
default_language_deletion_description:
'{{language}} is set as your default language and cant be deleted. ', // UNTRANSLATED
got_it: 'Got It', // UNTRANSLATED
},
authentication: {
title: '身份验证',

View file

@ -1,21 +1,3 @@
import { LanguageKey, languageKeyGuard } from '@logto/core-kit';
import en from './locales/en';
/* Copied from i18next/index.d.ts */
export type Resource = Record<LanguageKey, ResourceLanguage>;
export type ResourceLanguage = Record<string, ResourceKey>;
export type ResourceKey = string | Record<string, unknown>;
const languageCodeAndDisplayNameMappings: Record<LanguageKey, string> = {
en: 'English',
fr: 'Français',
'pt-PT': 'Português',
'zh-CN': '简体中文',
'tr-TR': 'Türkçe',
'ko-KR': '한국어',
};
export const languageOptions: Array<{ value: LanguageKey; title: string }> = Object.entries(
languageCodeAndDisplayNameMappings
).map(([key, value]) => ({ value: languageKeyGuard.parse(key), title: value }));
export type LocalPhrase = typeof en;

View file

@ -0,0 +1,14 @@
import { sql } from 'slonik';
import { AlterationScript } from '../lib/types/alteration';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`alter table custom_phrases rename column language_key to language_tag;`);
},
down: async (pool) => {
await pool.query(sql`alter table custom_phrases rename column language_tag to language_key;`);
},
};
export default alteration;

View file

@ -65,6 +65,7 @@
"dependencies": {
"@logto/connector-kit": "^1.0.0-beta.13",
"@logto/core-kit": "^1.0.0-beta.13",
"@logto/language-kit": "^1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.10",
"@logto/phrases-ui": "^1.0.0-beta.10",
"zod": "^3.18.0"

View file

@ -1,4 +1,5 @@
import { hexColorRegEx, languageKeys } from '@logto/core-kit';
import { languageTagGuard } from '@logto/language-kit';
import { z } from 'zod';
/**
@ -102,7 +103,8 @@ export type TermsOfUse = z.infer<typeof termsOfUseGuard>;
export const languageInfoGuard = z.object({
autoDetect: z.boolean(),
fallbackLanguage: z.enum(languageKeys),
fallbackLanguage: languageTagGuard,
/** @deprecated */
fixedLanguage: z.enum(languageKeys),
});

View file

@ -1,5 +1,5 @@
create table custom_phrases (
language_key varchar(16) not null,
language_tag varchar(16) not null,
translation jsonb /* @use Translation */ not null,
primary key(language_key)
primary key(language_tag)
);

View file

@ -18,7 +18,7 @@ import SignIn from './pages/SignIn';
import SocialLanding from './pages/SocialLanding';
import SocialRegister from './pages/SocialRegister';
import SocialSignIn from './pages/SocialSignInCallback';
import getSignInExperienceSettings from './utils/sign-in-experience';
import { getSignInExperienceSettings } from './utils/sign-in-experience';
import './scss/normalized.scss';
@ -35,9 +35,10 @@ const App = () => {
(async () => {
const settings = await getSignInExperienceSettings();
// Note: i18n must be initialized ahead of global experience settings
// Note: i18n must be initialized ahead of page render
await initI18n(settings.languageInfo);
// Init the page settings and render
setExperienceSettings(settings);
})();
}, [isPreview, setExperienceSettings, setLoading]);

View file

@ -9,3 +9,18 @@ import ky from 'ky';
export const getSignInExperience = async <T extends SignInExperience>(): Promise<T> => {
return ky.get('/api/.well-known/sign-in-exp').json<T>();
};
export const getPhrases = async (lng?: string) =>
ky
.extend({
hooks: {
beforeRequest: [
(request) => {
if (lng) {
request.headers.set('Accept-Language', lng);
}
},
],
},
})
.get('/api/phrase');

View file

@ -1,11 +1,11 @@
import { ConnectorPlatform } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import i18next from 'i18next';
import { useEffect, useState } from 'react';
import * as styles from '@/App.module.scss';
import { Context } from '@/hooks/use-page-context';
import initI18n from '@/i18n/init';
import { changeLanguage } from '@/i18n/utils';
import { SignInExperienceSettings, PreviewConfig } from '@/types';
import { parseQueryParameters } from '@/utils';
import { getPrimarySignInMethod, getSecondarySignInMethods } from '@/utils/sign-in-experience';
@ -24,7 +24,7 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
}
// Init i18n
void initI18n(undefined, isPreview);
void initI18n();
// Block pointer event
document.body.classList.add(conditionalString(styles.preview));
@ -75,13 +75,15 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
),
};
void i18next.changeLanguage(language);
(async () => {
await changeLanguage(language);
setTheme(mode);
setTheme(mode);
setPlatform(platform);
setPlatform(platform);
setExperienceSettings(experienceSettings);
setExperienceSettings(experienceSettings);
})();
}, [isPreview, previewConfig, setExperienceSettings, setPlatform, setTheme]);
return [isPreview, previewConfig];

View file

@ -1,35 +1,30 @@
import resources from '@logto/phrases-ui';
import { LanguageInfo } from '@logto/schemas';
import i18next, { InitOptions } from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import { getI18nResource, detectLanguage } from '@/i18n/utils';
const storageKey = 'i18nextLogtoUiLng';
const initI18n = async (languageSettings?: LanguageInfo, isPreview = false) => {
const initI18n = async (languageSettings?: LanguageInfo) => {
// Get language settings from the SIE
const locale = detectLanguage(languageSettings);
const { resources, lng } = await getI18nResource(locale);
const options: InitOptions = {
resources,
fallbackLng: languageSettings?.fallbackLanguage ?? 'en',
lng: languageSettings?.autoDetect === false ? languageSettings.fixedLanguage : undefined,
lng,
interpolation: {
escapeValue: false,
},
detection: {
lookupLocalStorage: storageKey,
lookupSessionStorage: storageKey,
},
};
i18next.use(initReactI18next);
// Should not apply auto detect if is preview or fix
if (!isPreview && !(languageSettings?.autoDetect === false)) {
i18next.use(LanguageDetector);
}
const i18n = i18next.init(options);
// @ts-expect-error - i18next doesn't have a type definition for this. called after i18next is initialized
// @ts-expect-error - i18next doesn't have a type definition for this. Must called after i18next is initialized
i18next.services.formatter.add('zhOrSpaces', (value: string, lng) => {
if (lng !== 'zh-CN') {
return value;

View file

@ -0,0 +1,62 @@
import resource, { LocalePhrase } from '@logto/phrases-ui';
import { LanguageInfo } from '@logto/schemas';
import i18next, { Resource } from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { getPhrases } from '@/apis/settings';
export const getI18nResource = async (
locale?: string | string[]
): Promise<{ resources: Resource; lng: string }> => {
try {
const response = await getPhrases(Array.isArray(locale) ? locale.join(' ') : locale);
const phrases = await response.json<LocalePhrase>();
const lng = response.headers.get('Content-Language');
if (!lng) {
throw new Error('lng not found');
}
return {
resources: { [lng]: phrases },
lng,
};
} catch {
// Fallback to build in en
return {
resources: { en: resource.en },
lng: 'en',
};
}
};
const storageKey = 'i18nextLogtoUiLng';
export const detectLanguage = (languageSettings?: LanguageInfo) => {
if (languageSettings?.autoDetect === false) {
return languageSettings.fallbackLanguage;
}
const languageDetector = new LanguageDetector();
languageDetector.init(
// Pass in a empty i18n languageUtils server instance to bypass the [languageDetector detection](https://github.com/i18next/i18next-browser-languageDetector/blob/master/src/index.js#L70)
{ languageUtils: {} },
{
lookupLocalStorage: storageKey,
lookupSessionStorage: storageKey,
}
);
return languageDetector.detect();
};
// Must be called after i18n's initialization
export const changeLanguage = async (targetLanguage: string) => {
const { resources, lng } = await getI18nResource(targetLanguage);
for (const [namespace, resource] of Object.entries(resources[lng] ?? {})) {
i18next.addResourceBundle(lng, namespace, resource);
}
await i18next.changeLanguage(lng);
};

View file

@ -1,4 +1,3 @@
import type { LanguageKey } from '@logto/core-kit';
import { SignInExperience, ConnectorMetadata, AppearanceMode } from '@logto/schemas';
export type UserFlow = 'sign-in' | 'register' | 'forgot-password';
@ -34,7 +33,7 @@ export enum ConfirmModalMessage {
export type PreviewConfig = {
signInExperience: SignInExperienceSettingsResponse;
language: LanguageKey;
language: string;
mode: AppearanceMode.LightMode | AppearanceMode.DarkMode;
platform: Platform;
isNative: boolean;

View file

@ -1,7 +1,7 @@
import { mockSignInExperience } from '@/__mocks__/logto';
import { getSignInExperience } from '@/apis/settings';
import getSignInExperienceSettings from './sign-in-experience';
import { getSignInExperienceSettings } from './sign-in-experience';
jest.mock('@/apis/settings', () => ({
getSignInExperience: jest.fn(),

View file

@ -30,7 +30,7 @@ export const getSecondarySignInMethods = (signInMethods: SignInMethods) =>
return methods;
}, []);
const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
export const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
const { signInMethods, socialConnectors, ...rest } =
await getSignInExperience<SignInExperienceSettingsResponse>();
@ -41,5 +41,3 @@ const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings>
socialConnectors: filterSocialConnectors(socialConnectors),
};
};
export default getSignInExperienceSettings;

87
pnpm-lock.yaml generated
View file

@ -102,7 +102,8 @@ importers:
packages/console:
specifiers:
'@fontsource/roboto-mono': ^4.5.7
'@logto/core-kit': ^1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.10
'@logto/phrases-ui': ^1.0.0-beta.10
'@logto/react': 1.0.0-beta.8
@ -127,9 +128,11 @@ importers:
'@types/react-modal': ^3.13.1
'@types/react-syntax-highlighter': ^15.5.1
classnames: ^2.3.1
clean-deep: ^3.4.0
cross-env: ^7.0.3
csstype: ^3.0.11
dayjs: ^1.10.5
deepmerge: ^4.2.2
dnd-core: ^16.0.0
eslint: ^8.21.0
history: ^5.3.0
@ -166,7 +169,8 @@ importers:
zod: ^3.18.0
devDependencies:
'@fontsource/roboto-mono': 4.5.7
'@logto/core-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui
'@logto/react': 1.0.0-beta.8_react@18.2.0
@ -191,9 +195,11 @@ importers:
'@types/react-modal': 3.13.1
'@types/react-syntax-highlighter': 15.5.1
classnames: 2.3.1
clean-deep: 3.4.0
cross-env: 7.0.3
csstype: 3.0.11
dayjs: 1.10.7
deepmerge: 4.2.2
dnd-core: 16.0.0
eslint: 8.21.0
history: 5.3.0
@ -233,8 +239,10 @@ importers:
specifiers:
'@logto/cli': ^1.0.0-beta.10
'@logto/connector-kit': ^1.0.0-beta.13
'@logto/core-kit': ^1.0.0-beta.13
'@logto/core-kit': ^1.0.0-beta.16
'@logto/language-kit': ^1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.10
'@logto/phrases-ui': ^1.0.0-beta.10
'@logto/schemas': ^1.0.0-beta.10
'@shopify/jest-koa-mocks': ^5.0.0
'@silverhand/eslint-config': 1.0.0
@ -260,6 +268,7 @@ importers:
'@types/supertest': ^2.0.11
'@types/tar': ^6.1.2
chalk: ^4
clean-deep: ^3.4.0
copyfiles: ^2.4.1
dayjs: ^1.10.5
debug: ^4.3.4
@ -312,11 +321,14 @@ importers:
dependencies:
'@logto/cli': link:../cli
'@logto/connector-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui
'@logto/schemas': link:../schemas
'@silverhand/essentials': 1.2.1
chalk: 4.1.2
clean-deep: 3.4.0
dayjs: 1.10.7
debug: 4.3.4
decamelize: 5.0.1
@ -393,6 +405,7 @@ importers:
packages/demo-app:
specifiers:
'@logto/core-kit': ^1.0.0-beta.13
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.10
'@logto/react': 1.0.0-beta.8
'@logto/schemas': ^1.0.0-beta.10
@ -419,6 +432,7 @@ importers:
typescript: ^4.7.4
devDependencies:
'@logto/core-kit': 1.0.0-beta.13
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': link:../phrases
'@logto/react': 1.0.0-beta.8_react@18.2.0
'@logto/schemas': link:../schemas
@ -498,7 +512,8 @@ importers:
packages/phrases:
specifiers:
'@logto/core-kit': ^1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.15
'@silverhand/eslint-config': 1.0.0
'@silverhand/essentials': ^1.2.1
'@silverhand/ts-config': 1.0.0
@ -506,9 +521,12 @@ importers:
lint-staged: ^13.0.0
prettier: ^2.7.1
typescript: ^4.7.4
zod: ^3.18.0
dependencies:
'@logto/core-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.15
'@silverhand/essentials': 1.2.1
zod: 3.18.0
devDependencies:
'@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni
'@silverhand/ts-config': 1.0.0_typescript@4.7.4
@ -519,7 +537,8 @@ importers:
packages/phrases-ui:
specifiers:
'@logto/core-kit': ^1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@silverhand/eslint-config': 1.0.0
'@silverhand/essentials': ^1.2.1
'@silverhand/ts-config': 1.0.0
@ -527,9 +546,12 @@ importers:
lint-staged: ^13.0.0
prettier: ^2.7.1
typescript: ^4.7.4
zod: ^3.18.0
dependencies:
'@logto/core-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@silverhand/essentials': 1.2.1
zod: 3.18.0
devDependencies:
'@silverhand/eslint-config': 1.0.0_swk2g7ygmfleszo5c33j4vooni
'@silverhand/ts-config': 1.0.0_typescript@4.7.4
@ -542,6 +564,7 @@ importers:
specifiers:
'@logto/connector-kit': ^1.0.0-beta.13
'@logto/core-kit': ^1.0.0-beta.13
'@logto/language-kit': ^1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.10
'@logto/phrases-ui': ^1.0.0-beta.10
'@silverhand/eslint-config': 1.0.0
@ -566,6 +589,7 @@ importers:
dependencies:
'@logto/connector-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.13
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui
zod: 3.18.0
@ -2533,7 +2557,7 @@ packages:
resolution: {integrity: sha512-bUgAaba1RkYIjHMPrcefLTJAf0d1uVhr864Gh/4JO7/du3xmbFbAWzDyfOhwzt1YZoG4NaMmjVXicfSh/zF/YQ==}
engines: {node: ^16.0.0}
dependencies:
'@logto/core-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@silverhand/essentials': 1.2.1
zod: 3.18.0
dev: false
@ -2555,6 +2579,15 @@ packages:
zod: 3.18.0
dev: true
/@logto/core-kit/1.0.0-beta.16:
resolution: {integrity: sha512-6f03W+FczXOusWPB9PiMz8mIMIaylYEyPSmICmN8YmIUJgD7jN1tHRJSZjnuo/8h2L6szm/8oNGqcxgIpBjeWg==}
engines: {node: ^16.0.0}
dependencies:
'@logto/language-kit': 1.0.0-beta.16
color: 4.2.3
nanoid: 3.3.4
zod: 3.18.0
/@logto/js/1.0.0-beta.8:
resolution: {integrity: sha512-Xo63sYZHnoFV+UTCtxRvGk+RuC3yJ3aDEpVDqShSZyfqY0xwGU5naIWHQXOd4koqkAQtp613AdL0qsEshrSl7Q==}
dependencies:
@ -2565,6 +2598,19 @@ packages:
lodash.get: 4.4.2
dev: true
/@logto/language-kit/1.0.0-beta.15:
resolution: {integrity: sha512-gRwE1E1+pItO/LsczC+hj02/wcIVjPJFB4qFBz580gTaa3rhWVYoZZ0DIGqZMQ0fMZ9Qwq7ncG47lS06gKBPmA==}
engines: {node: ^16.0.0}
dependencies:
zod: 3.18.0
dev: false
/@logto/language-kit/1.0.0-beta.16:
resolution: {integrity: sha512-1F7o6DlBrmK9QgZNOzf5RmWjN+36Pak2qfiu7NbMRRAlmNLmADqA0Is6v+cfRb6PMCPmRgbn9Iad5Q7NZctDVg==}
engines: {node: ^16.0.0}
dependencies:
zod: 3.18.0
/@logto/node/1.0.0-beta.8:
resolution: {integrity: sha512-IsQeFGhycUyX1R3/DiODK/nL2tsZaoeITKgqeJP68jiJnO51wPT9ewlFnJ656l/4bCA791oH71WTeeSvKJ8L2w==}
dependencies:
@ -3957,7 +4003,7 @@ packages:
'@jest/types': 28.1.3
deepmerge: 4.2.2
identity-obj-proxy: 3.0.0
jest: 28.1.3_@types+node@16.11.12
jest: 28.1.3_k5ytkvaprncdyzidqqws5bqksq
jest-matcher-specific-error: 1.0.0
jest-transform-stub: 2.0.0
ts-jest: 28.0.7_lhw3xkmzugq5tscs3x2ndm4sby
@ -5729,6 +5775,14 @@ packages:
resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
dev: true
/clean-deep/3.4.0:
resolution: {integrity: sha512-Lo78NV5ItJL/jl+B5w0BycAisaieJGXK1qYi/9m4SjR8zbqmrUtO7Yhro40wEShGmmxs/aJLI/A+jNhdkXK8mw==}
engines: {node: '>=4'}
dependencies:
lodash.isempty: 4.4.0
lodash.isplainobject: 4.0.6
lodash.transform: 4.6.0
/clean-regexp/1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'}
@ -6077,8 +6131,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
dependencies:
is-text-path: 1.0.1
JSONStream: 1.3.5
is-text-path: 1.0.1
lodash: 4.17.21
meow: 8.1.2
split2: 3.2.2
@ -10511,10 +10565,16 @@ packages:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
dev: true
/lodash.isempty/4.4.0:
resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==}
/lodash.ismatch/4.4.0:
resolution: {integrity: sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=}
dev: true
/lodash.isplainobject/4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
/lodash.kebabcase/4.1.1:
resolution: {integrity: sha1-hImxyw0p/4gZXM7KRI/21swpXDY=}
dev: true
@ -10557,6 +10617,9 @@ packages:
lodash._reinterpolate: 3.0.0
dev: true
/lodash.transform/4.6.0:
resolution: {integrity: sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==}
/lodash.truncate/4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
dev: true
@ -15126,7 +15189,7 @@ packages:
'@jest/types': 28.1.3
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 28.1.3_@types+node@16.11.12
jest: 28.1.3_k5ytkvaprncdyzidqqws5bqksq
jest-util: 28.1.3
json5: 2.2.1
lodash.memoize: 4.1.2