diff --git a/portal/src/AppRoot.tsx b/portal/src/AppRoot.tsx index bb256916bc..aaae968331 100644 --- a/portal/src/AppRoot.tsx +++ b/portal/src/AppRoot.tsx @@ -91,6 +91,9 @@ const UISettingsScreen = lazy( const LocalizationConfigurationScreen = lazy( async () => import("./graphql/portal/LocalizationConfigurationScreen") ); +const CustomTextConfigurationScreen = lazy( + async () => import("./graphql/portal/CustomTextConfigurationScreen") +); const LanguagesConfigurationScreen = lazy( async () => import("./graphql/portal/LanguagesConfigurationScreen") ); @@ -420,29 +423,59 @@ const AppRoot: React.VFC = function AppRoot() { - + } + /> + }> + + + } + /> + }> - + } /> - + } - /> - }> - + } /> + + } + /> + }> + + + } + /> + + }> + + + } + /> @@ -544,30 +577,6 @@ const AppRoot: React.VFC = function AppRoot() { /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> } /> + }> + + + } + /> diff --git a/portal/src/ScreenNav.tsx b/portal/src/ScreenNav.tsx index f1d7593a5b..da4887c595 100644 --- a/portal/src/ScreenNav.tsx +++ b/portal/src/ScreenNav.tsx @@ -251,24 +251,31 @@ const ScreenNav: React.VFC = function ScreenNav(props) { url: `/project/${appID}/configuration/apps`, }, { - type: "link" as const, - textKey: "CustomDomainListScreen.title", - url: `/project/${appID}/custom-domains`, - }, - { - type: "link" as const, - textKey: "ScreenNav.smtp", - url: `/project/${appID}/configuration/smtp`, - }, - { - type: "link" as const, - textKey: "ScreenNav.ui-settings", - url: `/project/${appID}/configuration/ui-settings`, - }, - { - type: "link" as const, - textKey: "ScreenNav.localization", - url: `/project/${appID}/configuration/localization`, + type: "group" as const, + textKey: "ScreenNav.branding", + urlPrefix: `/project/${appID}/branding`, + children: [ + { + type: "link" as const, + textKey: "ScreenNav.ui-settings", + url: `/project/${appID}/branding/ui-settings`, + }, + { + type: "link" as const, + textKey: "ScreenNav.localization", + url: `/project/${appID}/branding/localization`, + }, + { + type: "link" as const, + textKey: "CustomDomainListScreen.title", + url: `/project/${appID}/branding/custom-domains`, + }, + { + type: "link" as const, + textKey: "ScreenNav.customText", + url: `/project/${appID}/branding/custom-text`, + }, + ], }, { type: "link" as const, @@ -336,6 +343,11 @@ const ScreenNav: React.VFC = function ScreenNav(props) { textKey: "ScreenNav.session", url: `/project/${appID}/advanced/session`, }, + { + type: "link" as const, + textKey: "ScreenNav.smtp", + url: `/project/${appID}/advanced/smtp`, + }, ], }, ...(auditLogEnabled diff --git a/portal/src/graphql/portal/CustomTextConfigurationScreen.module.css b/portal/src/graphql/portal/CustomTextConfigurationScreen.module.css new file mode 100644 index 0000000000..a2870e3a54 --- /dev/null +++ b/portal/src/graphql/portal/CustomTextConfigurationScreen.module.css @@ -0,0 +1,7 @@ +.widget { + @apply col-span-8 tablet:col-span-full; +} + +.translationEditorWidget { + margin-top: 0; +} diff --git a/portal/src/graphql/portal/CustomTextConfigurationScreen.tsx b/portal/src/graphql/portal/CustomTextConfigurationScreen.tsx new file mode 100644 index 0000000000..9e605a3a3d --- /dev/null +++ b/portal/src/graphql/portal/CustomTextConfigurationScreen.tsx @@ -0,0 +1,282 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { FormattedMessage } from "@oursky/react-messageformat"; +import cn from "classnames"; + +import { useAppAndSecretConfigQuery } from "./query/appAndSecretConfigQuery"; +import { + LanguageTag, + Resource, + ResourceDefinition, + ResourceSpecifier, + expandSpecifier, + specifierId, +} from "../../util/resource"; +import { + DEFAULT_TEMPLATE_LOCALE, + RESOURCE_TRANSLATION_JSON, +} from "../../resources"; +import { + ResourcesFormState, + useResourceForm, +} from "../../hook/useResourceForm"; +import FormContainer from "../../FormContainer"; +import ScreenContent from "../../ScreenContent"; +import ScreenTitle from "../../ScreenTitle"; +import ManageLanguageWidget from "./ManageLanguageWidget"; +import { useSystemConfig } from "../../context/SystemConfigContext"; +import EditTemplatesWidget, { + EditTemplatesWidgetSection, +} from "./EditTemplatesWidget"; + +import styles from "./CustomTextConfigurationScreen.module.css"; + +interface FormState extends ResourcesFormState { + supportedLanguages: string[]; + fallbackLanguage: string; + selectedLanguage: string; +} + +interface FormModel { + isLoading: boolean; + isUpdating: boolean; + isDirty: boolean; + loadError: unknown; + updateError: unknown; + state: FormState; + setState: (fn: (state: FormState) => FormState) => void; + reload: () => void; + reset: () => void; + save: () => Promise; +} + +const CustomTextConfigurationScreen: React.VFC = + function CustomTextConfigurationScreen() { + const { appID } = useParams() as { appID: string }; + const { gitCommitHash } = useSystemConfig(); + const config = useAppAndSecretConfigQuery(appID); + + const initialSupportedLanguages = useMemo(() => { + return ( + config.effectiveAppConfig?.localization?.supported_languages ?? [ + config.effectiveAppConfig?.localization?.fallback_language ?? + DEFAULT_TEMPLATE_LOCALE, + ] + ); + }, [config.effectiveAppConfig?.localization]); + + const specifiers = useMemo(() => { + const specifiers = []; + + const supportedLanguages = [...initialSupportedLanguages]; + if (!supportedLanguages.includes(DEFAULT_TEMPLATE_LOCALE)) { + supportedLanguages.push(DEFAULT_TEMPLATE_LOCALE); + } + + for (const locale of supportedLanguages) { + specifiers.push({ + def: RESOURCE_TRANSLATION_JSON, + locale, + extension: null, + }); + } + return specifiers; + }, [initialSupportedLanguages]); + + const resourceForm = useResourceForm(appID, specifiers); + + const [selectedLanguage, setSelectedLanguage] = + useState(null); + + const state = useMemo(() => { + const fallbackLanguage = + config.effectiveAppConfig?.localization?.fallback_language ?? + DEFAULT_TEMPLATE_LOCALE; + return { + supportedLanguages: config.effectiveAppConfig?.localization + ?.supported_languages ?? [fallbackLanguage], + fallbackLanguage, + resources: resourceForm.state.resources, + selectedLanguage: selectedLanguage ?? fallbackLanguage, + }; + }, [ + config.effectiveAppConfig?.localization, + resourceForm.state.resources, + selectedLanguage, + ]); + + const form: FormModel = useMemo( + () => ({ + isLoading: config.loading || resourceForm.isLoading, + isUpdating: resourceForm.isUpdating, + isDirty: resourceForm.isDirty, + loadError: config.error ?? resourceForm.loadError, + updateError: resourceForm.updateError, + state, + setState: (fn) => { + const newState = fn(state); + resourceForm.setState(() => ({ resources: newState.resources })); + setSelectedLanguage(newState.selectedLanguage); + }, + reload: () => { + // Previously is also a floating promise, so just log the error out + // to make linter happy + config.refetch().catch((err) => { + console.error("Reload config error", err); + throw err; + }); + resourceForm.reload(); + }, + reset: () => { + resourceForm.reset(); + setSelectedLanguage(state.fallbackLanguage); + }, + save: async (ignoreConflict: boolean = false) => { + await resourceForm.save(ignoreConflict); + }, + }), + [config, resourceForm, state] + ); + + const getValueFromState = useCallback( + ( + resources: Partial>, + selectedLanguage: string, + fallbackLanguage: string, + def: ResourceDefinition, + getValueFn: ( + resource: Resource | undefined + ) => string | undefined | null + ): string | undefined | null => { + const specifier: ResourceSpecifier = { + def, + locale: selectedLanguage, + extension: null, + }; + const value = getValueFn(resources[specifierId(specifier)]); + + if (value == null) { + const specifier: ResourceSpecifier = { + def, + locale: fallbackLanguage, + extension: null, + }; + return getValueFn(resources[specifierId(specifier)]); + } + + return value; + }, + [] + ); + + const getValue = useCallback( + (def: ResourceDefinition) => { + const selectedValue = getValueFromState( + form.state.resources, + form.state.selectedLanguage, + form.state.fallbackLanguage, + def, + (res) => res?.nullableValue ?? res?.effectiveData + ); + if (selectedValue != null) { + return selectedValue; + } + + return ( + getValueFromState( + form.state.resources, + DEFAULT_TEMPLATE_LOCALE, + form.state.fallbackLanguage, + def, + (res) => res?.effectiveData + ) ?? "" + ); + }, + [form.state, getValueFromState] + ); + + const getOnChange = useCallback( + (def: ResourceDefinition) => { + const specifier: ResourceSpecifier = { + def, + locale: form.state.selectedLanguage, + extension: null, + }; + return (value: string | undefined, _e: unknown) => { + form.setState((prev) => { + const updatedResources = { ...prev.resources }; + const resource: Resource = { + specifier, + path: expandSpecifier(specifier), + nullableValue: value ?? "", + effectiveData: + prev.resources[specifierId(specifier)]?.effectiveData, + }; + updatedResources[specifierId(resource.specifier)] = resource; + return { ...prev, resources: updatedResources }; + }); + }; + }, + [form] + ); + + const sectionsTranslationJSON: [EditTemplatesWidgetSection] = [ + { + key: "translation.json", + title: ( + + ), + items: [ + { + key: "translation.json", + title: ( + + ), + language: "json", + value: getValue(RESOURCE_TRANSLATION_JSON), + onChange: getOnChange(RESOURCE_TRANSLATION_JSON), + editor: "code", + }, + ], + }, + ]; + + return ( + + + + + +
+ +
+ +
+
+ ); + }; + +export default CustomTextConfigurationScreen; diff --git a/portal/src/graphql/portal/GetStartedScreen.tsx b/portal/src/graphql/portal/GetStartedScreen.tsx index ccc4b444d6..a19c6f8a73 100644 --- a/portal/src/graphql/portal/GetStartedScreen.tsx +++ b/portal/src/graphql/portal/GetStartedScreen.tsx @@ -104,7 +104,7 @@ function useCardSpecs(options: MakeCardSpecsOptions): CardSpec[] { () => ({ key: "customize_ui", iconSrc: iconCustomize, - internalHref: "~/configuration/ui-settings", + internalHref: "~/branding/ui-settings", onClick: (_e) => { capture("getStarted.clicked-customize"); }, diff --git a/portal/src/graphql/portal/LocalizationConfigurationScreen.module.css b/portal/src/graphql/portal/LocalizationConfigurationScreen.module.css index cfe5d537cb..f579ddab3a 100644 --- a/portal/src/graphql/portal/LocalizationConfigurationScreen.module.css +++ b/portal/src/graphql/portal/LocalizationConfigurationScreen.module.css @@ -1,4 +1,4 @@ -.titleContainer { +.descriptionContainer { display: flex; flex-direction: row; justify-content: space-between; diff --git a/portal/src/graphql/portal/LocalizationConfigurationScreen.tsx b/portal/src/graphql/portal/LocalizationConfigurationScreen.tsx index efa8b8e492..76234c06ee 100644 --- a/portal/src/graphql/portal/LocalizationConfigurationScreen.tsx +++ b/portal/src/graphql/portal/LocalizationConfigurationScreen.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { Pivot, PivotItem } from "@fluentui/react"; +import cn from "classnames"; import { Context, FormattedMessage } from "@oursky/react-messageformat"; import ShowLoading from "../../ShowLoading"; import ShowError from "../../ShowError"; @@ -53,36 +54,14 @@ import { specifierId, expandSpecifier, } from "../../util/resource"; -import { useResourceForm } from "../../hook/useResourceForm"; +import { + ResourcesFormState, + useResourceForm, +} from "../../hook/useResourceForm"; import FormContainer from "../../FormContainer"; -import { useSystemConfig } from "../../context/SystemConfigContext"; import styles from "./LocalizationConfigurationScreen.module.css"; import { useAppAndSecretConfigQuery } from "./query/appAndSecretConfigQuery"; -interface ResourcesFormState { - resources: Partial>; -} - -function constructResourcesFormState( - resources: Resource[] -): ResourcesFormState { - const resourceMap: Partial> = {}; - for (const r of resources) { - const id = specifierId(r.specifier); - // Multiple resources may use same specifier ID (images), - // use the first resource with non-empty values. - if ((resourceMap[id]?.nullableValue ?? "") === "") { - resourceMap[specifierId(r.specifier)] = r; - } - } - - return { resources: resourceMap }; -} - -function constructResources(state: ResourcesFormState): Resource[] { - return Object.values(state.resources).filter(Boolean) as Resource[]; -} - interface FormState extends ResourcesFormState { supportedLanguages: string[]; fallbackLanguage: string; @@ -116,7 +95,6 @@ const PIVOT_KEY_FORGOT_PASSWORD_CODE = "forgot_password_code"; const PIVOT_KEY_VERIFICATION = "verification"; const PIVOT_KEY_PASSWORDLESS_VIA_EMAIL = "passwordless_via_email"; const PIVOT_KEY_PASSWORDLESS_VIA_SMS = "passwordless_via_sms"; -const PIVOT_KEY_TRANSLATION_JSON = "translation.json"; const PIVOT_KEY_DEFAULT = PIVOT_KEY_FORGOT_PASSWORD_LINK; @@ -126,7 +104,6 @@ const ALL_PIVOT_KEYS = [ PIVOT_KEY_VERIFICATION, PIVOT_KEY_PASSWORDLESS_VIA_EMAIL, PIVOT_KEY_PASSWORDLESS_VIA_SMS, - PIVOT_KEY_TRANSLATION_JSON, ]; const ResourcesConfigurationContent: React.VFC = @@ -141,7 +118,6 @@ const ResourcesConfigurationContent: React.VFC { @@ -397,32 +373,6 @@ const ResourcesConfigurationContent: React.VFC - ), - items: [ - { - key: "translation.json", - title: ( - - ), - language: "json", - value: getValue(RESOURCE_TRANSLATION_JSON), - onChange: getOnChange(RESOURCE_TRANSLATION_JSON), - editor: "code", - }, - ], - }, - ]; - const sectionsForgotPasswordLink: EditTemplatesWidgetSection[] = [ { key: "email", @@ -728,11 +678,15 @@ const ResourcesConfigurationContent: React.VFC -
- - - + + + +
+ + +
- - - {/* Code editors might incorrectly fire change events when changing language Set key to selectedLanguage to ensure code editors always remount */} @@ -800,14 +751,6 @@ const ResourcesConfigurationContent: React.VFC ) : null} - - - @@ -878,12 +821,7 @@ const LocalizationConfigurationScreen: React.VFC = return specifiers; }, [initialSupportedLanguages]); - const resources = useResourceForm( - appID, - specifiers, - constructResourcesFormState, - constructResources - ); + const resources = useResourceForm(appID, specifiers); const state = useMemo(() => { const fallbackLanguage = diff --git a/portal/src/graphql/portal/LoginMethodConfigurationScreen.tsx b/portal/src/graphql/portal/LoginMethodConfigurationScreen.tsx index 09e8e5f7c8..9d0e4babb3 100644 --- a/portal/src/graphql/portal/LoginMethodConfigurationScreen.tsx +++ b/portal/src/graphql/portal/LoginMethodConfigurationScreen.tsx @@ -96,7 +96,10 @@ import ShowOnlyIfSIWEIsDisabled from "./ShowOnlyIfSIWEIsDisabled"; import BlueMessageBar from "../../BlueMessageBar"; import { useTagPickerWithNewTags } from "../../hook/useInput"; import { fixTagPickerStyles } from "../../bugs"; -import { useResourceForm } from "../../hook/useResourceForm"; +import { + ResourcesFormState, + useResourceForm, +} from "../../hook/useResourceForm"; import { useAppFeatureConfigQuery } from "./query/appFeatureConfigQuery"; import { makeValidationErrorMatchUnknownKindParseRule, @@ -194,30 +197,6 @@ const specifiers: ResourceSpecifier[] = [ usernameExcludeKeywordsTXTSpecifier, ]; -interface ResourcesFormState { - resources: Partial>; -} - -function constructResourcesFormState( - resources: Resource[] -): ResourcesFormState { - const resourceMap: Partial> = {}; - for (const r of resources) { - const id = specifierId(r.specifier); - // Multiple resources may use same specifier ID (images), - // use the first resource with non-empty values. - if ((resourceMap[id]?.nullableValue ?? "") === "") { - resourceMap[specifierId(r.specifier)] = r; - } - } - - return { resources: resourceMap }; -} - -function constructResources(state: ResourcesFormState): Resource[] { - return Object.values(state.resources).filter(Boolean) as Resource[]; -} - type LoginMethodPasswordlessLoginID = "email" | "phone" | "phone-email"; type LoginMethodPasswordLoginID = | "email" @@ -3535,12 +3514,7 @@ const LoginMethodConfigurationScreen: React.VFC = validate: validateFormState, }); - const resourceForm = useResourceForm( - appID, - specifiers, - constructResourcesFormState, - constructResources - ); + const resourceForm = useResourceForm(appID, specifiers); const state = useMemo(() => { return { diff --git a/portal/src/graphql/portal/ManageLanguageWidget.tsx b/portal/src/graphql/portal/ManageLanguageWidget.tsx index 8674af3b50..b16ba77c0f 100644 --- a/portal/src/graphql/portal/ManageLanguageWidget.tsx +++ b/portal/src/graphql/portal/ManageLanguageWidget.tsx @@ -15,6 +15,7 @@ import styles from "./ManageLanguageWidget.module.css"; interface ManageLanguageWidgetProps { className?: string; + showLabel?: boolean; // The supported languages. existingLanguages: LanguageTag[]; @@ -41,6 +42,7 @@ const ManageLanguageWidget: React.VFC = selectedLanguage, onChangeSelectedLanguage, fallbackLanguage, + showLabel = true, } = props; const { renderToString } = useContext(Context); @@ -150,9 +152,11 @@ const ManageLanguageWidget: React.VFC = <>
- + {showLabel ? ( + + ) : null}
>; -} - -function constructResourcesFormState( - resources: Resource[] -): ResourcesFormState { - const resourceMap: Partial> = {}; - for (const r of resources) { - const id = specifierId(r.specifier); - // Multiple resources may use same specifier ID (images), - // use the first resource with non-empty values. - if ((resourceMap[id]?.nullableValue ?? "") === "") { - resourceMap[specifierId(r.specifier)] = r; - } - } - - return { resources: resourceMap }; -} - -function constructResources(state: ResourcesFormState): Resource[] { - return Object.values(state.resources).filter(Boolean) as Resource[]; -} - interface FormState extends ConfigFormState, ResourcesFormState, @@ -811,12 +790,7 @@ const UISettingsScreen: React.VFC = function UISettingsScreen() { return specifiers; }, [initialSupportedLanguages]); - const resources = useResourceForm( - appID, - specifiers, - constructResourcesFormState, - constructResources - ); + const resources = useResourceForm(appID, specifiers); const state = useMemo( () => ({ diff --git a/portal/src/graphql/portal/VerifyDomainScreen.tsx b/portal/src/graphql/portal/VerifyDomainScreen.tsx index b8337d2c4c..ae77a2db0c 100644 --- a/portal/src/graphql/portal/VerifyDomainScreen.tsx +++ b/portal/src/graphql/portal/VerifyDomainScreen.tsx @@ -122,7 +122,7 @@ const VerifyDomain: React.VFC = function VerifyDomain( const navBreadcrumbItems = useMemo(() => { return [ { - to: `/project/${appID}/custom-domains`, + to: `/project/${appID}/branding/custom-domains`, label: , }, { to: ".", label: }, diff --git a/portal/src/hook/useResourceForm.ts b/portal/src/hook/useResourceForm.ts index cc60c03e8a..9d6c9c0529 100644 --- a/portal/src/hook/useResourceForm.ts +++ b/portal/src/hook/useResourceForm.ts @@ -4,6 +4,7 @@ import { ResourceSpecifier, ResourcesDiffResult, diffResourceUpdates, + specifierId, } from "../util/resource"; import { useAppTemplatesQuery } from "../graphql/portal/query/appTemplatesQuery"; import { useUpdateAppTemplatesMutation } from "../graphql/portal/mutations/updateAppTemplatesMutation"; @@ -25,11 +26,46 @@ export interface ResourceFormModel { export type StateConstructor = (resources: Resource[]) => State; export type ResourcesConstructor = (state: State) => Resource[]; +export interface ResourcesFormState { + resources: Partial>; +} + +function constructResourcesFormStateFromResources( + resources: Resource[] +): ResourcesFormState { + const resourceMap: Partial> = {}; + for (const r of resources) { + const id = specifierId(r.specifier); + // Multiple resources may use same specifier ID (images), + // use the first resource with non-empty values. + if ((resourceMap[id]?.nullableValue ?? "") === "") { + resourceMap[specifierId(r.specifier)] = r; + } + } + + return { resources: resourceMap }; +} + +function constructResourcesFromResourcesFormState( + state: ResourcesFormState +): Resource[] { + return Object.values(state.resources).filter(Boolean) as Resource[]; +} +export function useResourceForm( + appID: string, + specifiers: ResourceSpecifier[] +): ResourceFormModel; export function useResourceForm( appID: string, specifiers: ResourceSpecifier[], constructState: StateConstructor, constructResources: ResourcesConstructor +): ResourceFormModel; +export function useResourceForm( + appID: string, + specifiers: ResourceSpecifier[], + constructState: StateConstructor = constructResourcesFormStateFromResources, + constructResources: ResourcesConstructor = constructResourcesFromResourcesFormState ): ResourceFormModel { const { resources, diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index 22c4f44094..92723076be 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -157,7 +157,9 @@ "ScreenNav.login-methods": "Login Methods", "ScreenNav.client-applications": "Applications", "ScreenNav.ui-settings": "UI Settings", - "ScreenNav.localization": "Localization", + "ScreenNav.localization": "Email/SMS Templates", + "ScreenNav.branding": "Branding", + "ScreenNav.customText": "Custom Text", "ScreenNav.languages": "Languages", "ScreenNav.user-profile": "User Profile", "ScreenNav.standard-attributes": "Standard Attributes", @@ -898,16 +900,17 @@ "UISettingsScreen.branding.title": "Authgear branding", "UISettingsScreen.branding.disable-authgear-logo.label": "Display Authgear logo in Login and Signup page", - "LocalizationConfigurationScreen.title": "Localization", + "LocalizationConfigurationScreen.title": "Email/SMS Templates", "LocalizationConfigurationScreen.description": "Update template contents and localizations.", "LocalizationConfigurationScreen.template-content-title": "Update Template Contents", - "LocalizationConfigurationScreen.translationjson.title": "Other Translations", "LocalizationConfigurationScreen.forgot-password-link.title": "Reset Password by Link", "LocalizationConfigurationScreen.forgot-password-code.title": "Reset Password by OTP", "LocalizationConfigurationScreen.verification.title": "Verification", "LocalizationConfigurationScreen.passwordless-via-email.title": "Passwordless via Email", "LocalizationConfigurationScreen.passwordless-via-sms.title": "Passwordless via SMS", + "CustomTextConfigurationScreen.title": "Custom Text", + "LanguagesConfigurationScreen.title": "Languages", "LanguagesConfigurationScreen.selectPrimaryLanguageWidget.title": "Primary language", "LanguagesConfigurationScreen.selectPrimaryLanguageWidget.description": "Choose the language will be displayed if the user's locale does not match any other supported languages.",