diff --git a/pkg/lib/config/configsource/resources.go b/pkg/lib/config/configsource/resources.go index c5fbe9031a..9668151a18 100644 --- a/pkg/lib/config/configsource/resources.go +++ b/pkg/lib/config/configsource/resources.go @@ -220,6 +220,10 @@ func (d AuthgearYAMLDescriptor) validateFeatureConfig(validationCtx *validation. if incomingFCError == nil || !ok { return incomingFCError } + // https://github.com/authgear/authgear-server/commit/888e57b4b6fa9de7cd5786111cdc5cc244a85ac0 + // If the original config has some feature config error, we allow the user + // to save the config without correcting them. This is for the case that + // the app is downgraded from a higher plan. originalFCError := d.validateBasedOnFeatureConfig(original, fc) originalAggregatedError, ok := originalFCError.(*validation.AggregatedError) if originalFCError == nil || !ok { @@ -519,6 +523,11 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re return nil, fmt.Errorf("cannot delete '%v'", AuthgearSecretYAML) } + fc, ok := ctx.Value(ContextKeyFeatureConfig).(*config.FeatureConfig) + if !ok || fc == nil { + return nil, fmt.Errorf("missing feature config in context") + } + var original *config.SecretConfig original, err := config.ParseSecret(resrc.Data) if err != nil { @@ -548,12 +557,17 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re return config.GenerateSAMLIdpSigningCertificate(commonName) }, } - updatedConfig, err := updateInstruction.ApplyTo(updateInstructionContext, original) + incoming, err := updateInstruction.ApplyTo(updateInstructionContext, original) if err != nil { return nil, err } - updatedYAML, err := yaml.Marshal(updatedConfig) + err = d.validate(ctx, original, incoming, fc) + if err != nil { + return nil, err + } + + updatedYAML, err := yaml.Marshal(incoming) if err != nil { return nil, err } @@ -563,6 +577,46 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re return &newResrc, nil } +func (d AuthgearSecretYAMLDescriptor) validate(ctx context.Context, original *config.SecretConfig, incoming *config.SecretConfig, fc *config.FeatureConfig) error { + validationCtx := &validation.Context{} + + featureConfigErr := func() error { + incomingFCError := d.validateBasedOnFeatureConfig(incoming, fc) + incomingAggregatedError, ok := incomingFCError.(*validation.AggregatedError) + if incomingFCError == nil || !ok { + return incomingFCError + } + // https://github.com/authgear/authgear-server/commit/888e57b4b6fa9de7cd5786111cdc5cc244a85ac0 + // If the original config has some feature config error, we allow the user + // to save the config without correcting them. This is for the case that + // the app is downgraded from a higher plan. + originalFCError := d.validateBasedOnFeatureConfig(original, fc) + originalAggregatedError, ok := originalFCError.(*validation.AggregatedError) + if originalFCError == nil || !ok { + return incomingFCError + } + + aggregatedError := incomingAggregatedError.Subtract(originalAggregatedError) + return aggregatedError + }() + + validationCtx.AddError(featureConfigErr) + + return validationCtx.Error(fmt.Sprintf("invalid %v", AuthgearSecretYAML)) +} + +func (d AuthgearSecretYAMLDescriptor) validateBasedOnFeatureConfig(secretConfig *config.SecretConfig, fc *config.FeatureConfig) error { + validationCtx := &validation.Context{} + + if fc.Messaging.CustomSMTPDisabled { + if _, _, ok := secretConfig.Lookup(config.SMTPServerCredentialsKey); ok { + validationCtx.EmitErrorMessage("custom smtp is not allowed") + } + } + + return validationCtx.Error("features are limited by feature config") +} + var SecretConfig = resource.RegisterResource(AuthgearSecretYAMLDescriptor{}) type AuthgearFeatureYAMLDescriptor struct{} diff --git a/pkg/lib/config/feature_messaging.go b/pkg/lib/config/feature_messaging.go index c0041ef70a..f1897862fe 100644 --- a/pkg/lib/config/feature_messaging.go +++ b/pkg/lib/config/feature_messaging.go @@ -11,7 +11,8 @@ var _ = FeatureConfigSchema.Add("MessagingFeatureConfig", ` "whatsapp_usage": { "$ref": "#/$defs/UsageLimitConfig" }, "sms_usage_count_disabled": { "type": "boolean" }, "whatsapp_usage_count_disabled": { "type": "boolean" }, - "template_customization_disabled": { "type": "boolean" } + "template_customization_disabled": { "type": "boolean" }, + "custom_smtp_disabled": { "type": "boolean" } } } `) @@ -27,6 +28,8 @@ type MessagingFeatureConfig struct { WhatsappUsageCountDisabled bool `json:"whatsapp_usage_count_disabled,omitempty"` TemplateCustomizationDisabled bool `json:"template_customization_disabled,omitempty"` + + CustomSMTPDisabled bool `json:"custom_smtp_disabled,omitempty"` } func (c *MessagingFeatureConfig) SetDefaults() { diff --git a/portal/src/BlueMessageBar.tsx b/portal/src/BlueMessageBar.tsx index 86be8bfa17..0246582f64 100644 --- a/portal/src/BlueMessageBar.tsx +++ b/portal/src/BlueMessageBar.tsx @@ -56,7 +56,7 @@ export default function BlueMessageBar( ); return ( - + ); diff --git a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx index 07f61698a3..9ce69fbfdc 100644 --- a/portal/src/graphql/portal/SMTPConfigurationScreen.tsx +++ b/portal/src/graphql/portal/SMTPConfigurationScreen.tsx @@ -48,6 +48,8 @@ import DefaultButton from "../../DefaultButton"; import { AppSecretKey } from "./globalTypes.generated"; import { useLocationEffect } from "../../hook/useLocationEffect"; import { useAppSecretVisitToken } from "./mutations/generateAppSecretVisitTokenMutation"; +import { useAppFeatureConfigQuery } from "./query/appFeatureConfigQuery"; +import FeatureDisabledMessageBar from "./FeatureDisabledMessageBar"; interface LocationState { isEdit: boolean; @@ -245,13 +247,14 @@ function ProviderDescription(props: ProviderDescriptionProps) { } interface SMTPConfigurationScreenContentProps { + isCustomSMTPDisabled: boolean; sendTestEmailHandle: UseSendTestEmailMutationReturnType; form: AppSecretConfigFormModel; } const SMTPConfigurationScreenContent: React.VFC = function SMTPConfigurationScreenContent(props) { - const { form, sendTestEmailHandle } = props; + const { form, sendTestEmailHandle, isCustomSMTPDisabled } = props; const { state, setState } = form; const { sendTestEmail, loading } = sendTestEmailHandle; @@ -503,6 +506,12 @@ const SMTPConfigurationScreenContent: React.VFC + {isCustomSMTPDisabled ? ( + + ) : null} {state.enabled ? ( <> @@ -622,6 +631,7 @@ const SMTPConfigurationScreenContent: React.VFC} /> @@ -678,20 +688,33 @@ const SMTPConfigurationScreen1: React.VFC<{ constructConfig, constructSecretUpdateInstruction, }); + const featureConfig = useAppFeatureConfigQuery(appID); const sendTestEmailHandle = useSendTestEmailMutation(appID); - if (form.isLoading) { + if (form.isLoading || featureConfig.loading) { return ; } - if (form.loadError) { - return ; + if (form.loadError ?? featureConfig.error) { + return ( + { + form.reload(); + featureConfig.refetch().finally(() => {}); + }} + /> + ); } return ( diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index 06edcce25e..d58a5d8fb9 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1552,6 +1552,7 @@ "FeatureConfig.collaborator.contact-us": "Your plan only include {maximum, plural, one{1 member seat} other{# member seats}}, {ExternalLink, react, href{{contactUsHref}} children{contact us to upgrade} target{_blank}}", "FeatureConfig.web3-nft.maximum": "You can index {maximum, plural, one{1 collection} other{# collections}} max. {ReactRouterLink, react, to{{planPagePath}} children{Upgrade your project plan} target{_blank}} to add more", "FeatureConfig.edit-template.disabled": "{ReactRouterLink, react, to{{planPagePath}} children{Upgrade your project plan} target{_blank}} to change the templates.", + "FeatureConfig.custom-smtp.disabled": "Custom email provider is not available for your project plan. {ReactRouterLink, react, to{{planPagePath}} children{Upgrade your project plan} target{_blank}}.", "SubscriptionCurrentPlanSummary.title.known-plan": "Current Plan: {name} {expiredAt, select, false{(${amount}/mo)} other{(Expire at: {expiredAt})}}", "SubscriptionCurrentPlanSummary.title.custom-plan": "Custom Plan: {name}", "SubscriptionCurrentPlanSummary.label.free": "Free",