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:
commit
ee5fe004bb
97 changed files with 2600 additions and 357 deletions
|
@ -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",
|
||||
|
|
38
packages/console/src/hooks/use-ui-languages.ts
Normal file
38
packages/console/src/hooks/use-ui-languages.ts
Normal 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;
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -25,7 +25,7 @@ const OthersTab = ({ defaultData, isDataDirty }: Props) => {
|
|||
return (
|
||||
<>
|
||||
<TermsForm />
|
||||
<LanguagesForm />
|
||||
<LanguagesForm isManageLanguageVisible />
|
||||
<AuthenticationForm />
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDataDirty} />
|
||||
</>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
7
packages/console/src/types/custom-phrase.ts
Normal file
7
packages/console/src/types/custom-phrase.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { LanguageTag } from '@logto/language-kit';
|
||||
import { Translation } from '@logto/schemas';
|
||||
|
||||
export type CustomPhraseResponse = {
|
||||
languageTag: LanguageTag;
|
||||
translation: Translation;
|
||||
};
|
|
@ -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",
|
||||
|
|
66
packages/core/src/__mocks__/custom-phrase.ts
Normal file
66
packages/core/src/__mocks__/custom-phrase.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
112
packages/core/src/lib/phrase.test.ts
Normal file
112
packages/core/src/lib/phrase.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
24
packages/core/src/lib/phrase.ts
Normal file
24
packages/core/src/lib/phrase.ts
Normal 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))
|
||||
);
|
||||
};
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
152
packages/core/src/routes/phrase.content-language.test.ts
Normal file
152
packages/core/src/routes/phrase.content-language.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
157
packages/core/src/routes/phrase.test.ts
Normal file
157
packages/core/src/routes/phrase.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
45
packages/core/src/routes/phrase.ts
Normal file
45
packages/core/src/routes/phrase.ts
Normal 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();
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
42
packages/core/src/utils/translation.test.ts
Normal file
42
packages/core/src/utils/translation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
36
packages/core/src/utils/translation.ts
Normal file
36
packages/core/src/utils/translation.ts
Normal 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;
|
||||
};
|
|
@ -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/;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.',
|
||||
default_language_deletion_title: 'Default language can’t be deleted.',
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ',
|
||||
got_it: 'Got It',
|
||||
},
|
||||
authentication: {
|
||||
title: 'AUTHENTICATION',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.', // UNTRANSLATED
|
||||
default_language_deletion_title: 'Default language can’t be deleted.', // UNTRANSLATED
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED
|
||||
got_it: 'Got It', // UNTRANSLATED
|
||||
},
|
||||
authentication: {
|
||||
title: 'AUTHENTICATION',
|
||||
|
|
|
@ -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 설정을 확인해주세요.',
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ const general = {
|
|||
unsaved_changes_warning: '수정된 내용이 있어요. 정말로 현재 페이지를 벗어날까요?',
|
||||
leave_page: '페이지 나가기',
|
||||
stay_on_page: '페이지 유지하기',
|
||||
type_to_search: 'Type to search', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default general;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.', // UNTRANSLATED
|
||||
default_language_deletion_title: 'Default language can’t be deleted.', // UNTRANSLATED
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED
|
||||
got_it: 'Got It', // UNTRANSLATED
|
||||
},
|
||||
authentication: {
|
||||
title: 'AUTHENTICATION',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.', // UNTRANSLATED
|
||||
default_language_deletion_title: 'Default language can’t be deleted.', // UNTRANSLATED
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED
|
||||
got_it: 'Got It', // UNTRANSLATED
|
||||
},
|
||||
authentication: {
|
||||
title: 'AUTENTICAÇÃO',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.', // UNTRANSLATED
|
||||
default_language_deletion_title: 'Default language can’t be deleted.', // UNTRANSLATED
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED
|
||||
got_it: 'Got It', // UNTRANSLATED
|
||||
},
|
||||
authentication: {
|
||||
title: 'AUTHENTICATION',
|
||||
|
|
|
@ -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 配置。',
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ const general = {
|
|||
unsaved_changes_warning: '还有未保存的变更, 确定要离开吗?',
|
||||
leave_page: '离开此页',
|
||||
stay_on_page: '留在此页',
|
||||
type_to_search: 'Type to search', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default general;
|
||||
|
|
|
@ -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 won’t 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 won’t be able to browse in that language again.', // UNTRANSLATED
|
||||
default_language_deletion_title: 'Default language can’t be deleted.', // UNTRANSLATED
|
||||
default_language_deletion_description:
|
||||
'{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED
|
||||
got_it: 'Got It', // UNTRANSLATED
|
||||
},
|
||||
authentication: {
|
||||
title: '身份验证',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
|
62
packages/ui/src/i18n/utils.ts
Normal file
62
packages/ui/src/i18n/utils.ts
Normal 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);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
87
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue