diff --git a/docs/docs/apis/proto/admin.md b/docs/docs/apis/proto/admin.md index e725114422..59b19c0199 100644 --- a/docs/docs/apis/proto/admin.md +++ b/docs/docs/apis/proto/admin.md @@ -20,6 +20,18 @@ It respondes as soon as ZITADEL started GET: /healthz +### GetSupportedLanguages + +> **rpc** GetSupportedLanguages([GetSupportedLanguagesRequest](#getsupportedlanguagesrequest)) +[GetSupportedLanguagesResponse](#getsupportedlanguagesresponse) + +Returns the default languages + + + + GET: /languages + + ### IsOrgUnique > **rpc** IsOrgUnique([IsOrgUniqueRequest](#isorguniquerequest)) @@ -658,7 +670,19 @@ it impacts all organisations without a customised policy > **rpc** GetDefaultInitMessageText([GetDefaultInitMessageTextRequest](#getdefaultinitmessagetextrequest)) [GetDefaultInitMessageTextResponse](#getdefaultinitmessagetextresponse) -Returns the custom text for initial message +Returns the default text for initial message (translation file) + + + + GET: /text/default/message/init/{language} + + +### GetCustomInitMessageText + +> **rpc** GetCustomInitMessageText([GetCustomInitMessageTextRequest](#getcustominitmessagetextrequest)) +[GetCustomInitMessageTextResponse](#getcustominitmessagetextresponse) + +Returns the custom text for initial message (overwritten in eventstore) @@ -685,7 +709,19 @@ The Following Variables can be used: > **rpc** GetDefaultPasswordResetMessageText([GetDefaultPasswordResetMessageTextRequest](#getdefaultpasswordresetmessagetextrequest)) [GetDefaultPasswordResetMessageTextResponse](#getdefaultpasswordresetmessagetextresponse) -Returns the custom text for password reset message +Returns the default text for password reset message (translation file) + + + + GET: /text/deafult/message/passwordreset/{language} + + +### GetCustomPasswordResetMessageText + +> **rpc** GetCustomPasswordResetMessageText([GetCustomPasswordResetMessageTextRequest](#getcustompasswordresetmessagetextrequest)) +[GetCustomPasswordResetMessageTextResponse](#getcustompasswordresetmessagetextresponse) + +Returns the custom text for password reset message (overwritten in eventstore) @@ -712,7 +748,19 @@ The Following Variables can be used: > **rpc** GetDefaultVerifyEmailMessageText([GetDefaultVerifyEmailMessageTextRequest](#getdefaultverifyemailmessagetextrequest)) [GetDefaultVerifyEmailMessageTextResponse](#getdefaultverifyemailmessagetextresponse) -Returns the custom text for verify email message +Returns the default text for verify email message (translation files) + + + + GET: /text/default/message/verifyemail/{language} + + +### GetCustomVerifyEmailMessageText + +> **rpc** GetCustomVerifyEmailMessageText([GetCustomVerifyEmailMessageTextRequest](#getcustomverifyemailmessagetextrequest)) +[GetCustomVerifyEmailMessageTextResponse](#getcustomverifyemailmessagetextresponse) + +Returns the custom text for verify email message (overwritten in eventstore) @@ -739,6 +787,18 @@ The Following Variables can be used: > **rpc** GetDefaultVerifyPhoneMessageText([GetDefaultVerifyPhoneMessageTextRequest](#getdefaultverifyphonemessagetextrequest)) [GetDefaultVerifyPhoneMessageTextResponse](#getdefaultverifyphonemessagetextresponse) +Returns the default text for verify phone message (translation file) + + + + GET: /text/default/message/verifyphone/{language} + + +### GetCustomVerifyPhoneMessageText + +> **rpc** GetCustomVerifyPhoneMessageText([GetCustomVerifyPhoneMessageTextRequest](#getcustomverifyphonemessagetextrequest)) +[GetCustomVerifyPhoneMessageTextResponse](#getcustomverifyphonemessagetextresponse) + Returns the custom text for verify phone message @@ -766,7 +826,19 @@ The Following Variables can be used: > **rpc** GetDefaultDomainClaimedMessageText([GetDefaultDomainClaimedMessageTextRequest](#getdefaultdomainclaimedmessagetextrequest)) [GetDefaultDomainClaimedMessageTextResponse](#getdefaultdomainclaimedmessagetextresponse) -Returns the custom text for domain claimed message +Returns the default text for domain claimed message (translation file) + + + + GET: /text/default/message/domainclaimed/{language} + + +### GetCustomDomainClaimedMessageText + +> **rpc** GetCustomDomainClaimedMessageText([GetCustomDomainClaimedMessageTextRequest](#getcustomdomainclaimedmessagetextrequest)) +[GetCustomDomainClaimedMessageTextResponse](#getcustomdomainclaimedmessagetextresponse) + +Returns the custom text for domain claimed message (overwritten in eventstore) @@ -1171,6 +1243,50 @@ This is an empty response +### GetCustomDomainClaimedMessageTextRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| language | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetCustomDomainClaimedMessageTextResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| custom_text | zitadel.text.v1.MessageCustomText | - | | + + + + +### GetCustomInitMessageTextRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| language | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetCustomInitMessageTextResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| custom_text | zitadel.text.v1.MessageCustomText | - | | + + + + ### GetCustomLoginTextsRequest @@ -1216,6 +1332,72 @@ This is an empty response +### GetCustomPasswordResetMessageTextRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| language | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetCustomPasswordResetMessageTextResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| custom_text | zitadel.text.v1.MessageCustomText | - | | + + + + +### GetCustomVerifyEmailMessageTextRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| language | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetCustomVerifyEmailMessageTextResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| custom_text | zitadel.text.v1.MessageCustomText | - | | + + + + +### GetCustomVerifyPhoneMessageTextRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| language | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### GetCustomVerifyPhoneMessageTextResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| custom_text | zitadel.text.v1.MessageCustomText | - | | + + + + ### GetDefaultDomainClaimedMessageTextRequest @@ -1567,6 +1749,23 @@ This is an empty request +### GetSupportedLanguagesRequest +This is an empty request + + + + +### GetSupportedLanguagesResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| languages | repeated string | - | | + + + + ### HealthzRequest This is an empty request diff --git a/docs/docs/apis/proto/auth.md b/docs/docs/apis/proto/auth.md index df1d7b210c..53467b240c 100644 --- a/docs/docs/apis/proto/auth.md +++ b/docs/docs/apis/proto/auth.md @@ -19,6 +19,18 @@ title: zitadel/auth.proto GET: /healthz +### GetSupportedLanguages + +> **rpc** GetSupportedLanguages([GetSupportedLanguagesRequest](#getsupportedlanguagesrequest)) +[GetSupportedLanguagesResponse](#getsupportedlanguagesresponse) + +Returns the default languages + + + + GET: /languages + + ### GetMyUser > **rpc** GetMyUser([GetMyUserRequest](#getmyuserrequest)) @@ -646,6 +658,23 @@ the request parameters are read from the token-header +### GetSupportedLanguagesRequest +This is an empty request + + + + +### GetSupportedLanguagesResponse +This is an empty response + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| languages | repeated string | - | | + + + + ### HealthzRequest This is an empty request diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md index 8df950489b..e314bca3ce 100644 --- a/docs/docs/apis/proto/management.md +++ b/docs/docs/apis/proto/management.md @@ -43,6 +43,18 @@ Returns some needed settings of the IAM (Global Organisation ID, Zitadel Project GET: /iam +### GetSupportedLanguages + +> **rpc** GetSupportedLanguages([GetSupportedLanguagesRequest](#getsupportedlanguagesrequest)) +[GetSupportedLanguagesResponse](#getsupportedlanguagesresponse) + +Returns the default languages + + + + GET: /languages + + ### GetUserByID > **rpc** GetUserByID([GetUserByIDRequest](#getuserbyidrequest)) @@ -4282,6 +4294,23 @@ This is an empty request +### GetSupportedLanguagesRequest +This is an empty request + + + + +### GetSupportedLanguagesResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| languages | repeated string | - | | + + + + ### GetUserByIDRequest diff --git a/internal/admin/repository/eventsourcing/eventstore/iam.go b/internal/admin/repository/eventsourcing/eventstore/iam.go index cd12615849..bc30343d34 100644 --- a/internal/admin/repository/eventsourcing/eventstore/iam.go +++ b/internal/admin/repository/eventsourcing/eventstore/iam.go @@ -10,10 +10,12 @@ import ( "sync" "github.com/ghodss/yaml" + "golang.org/x/text/language" "github.com/caos/zitadel/internal/domain" v1 "github.com/caos/zitadel/internal/eventstore/v1" "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/i18n" iam_view "github.com/caos/zitadel/internal/iam/repository/view" "github.com/caos/zitadel/internal/user/repository/view/model" @@ -30,15 +32,30 @@ import ( ) type IAMRepository struct { - Eventstore v1.Eventstore - SearchLimit uint64 - View *admin_view.View - SystemDefaults systemdefaults.SystemDefaults - Roles []string - PrefixAvatarURL string - LoginDir http.FileSystem - TranslationFileContents map[string][]byte - mutex sync.Mutex + Eventstore v1.Eventstore + SearchLimit uint64 + View *admin_view.View + SystemDefaults systemdefaults.SystemDefaults + Roles []string + PrefixAvatarURL string + LoginDir http.FileSystem + NotificationDir http.FileSystem + LoginTranslationFileContents map[string][]byte + NotificationTranslationFileContents map[string][]byte + mutex sync.Mutex + supportedLangs []language.Tag +} + +func (repo *IAMRepository) Languages(ctx context.Context) ([]language.Tag, error) { + if len(repo.supportedLangs) == 0 { + langs, err := i18n.SupportedLanguages(repo.LoginDir) + if err != nil { + logging.Log("ADMIN-tiMWs").WithError(err).Debug("unable to parse language") + return nil, err + } + repo.supportedLangs = langs + } + return repo.supportedLangs, nil } func (repo *IAMRepository) IAMMemberByID(ctx context.Context, iamID, userID string) (*iam_model.IAMMemberView, error) { @@ -365,21 +382,36 @@ func (repo *IAMRepository) SearchIAMMembersx(ctx context.Context, request *iam_m return result, nil } -func (repo *IAMRepository) GetDefaultMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) { - text, err := repo.View.MessageTexts(repo.SystemDefaults.IamID) - if err != nil { - return nil, err +func (repo *IAMRepository) GetDefaultMessageText(ctx context.Context, textType, lang string) (*domain.CustomMessageText, error) { + repo.mutex.Lock() + defer repo.mutex.Unlock() + var err error + contents, ok := repo.NotificationTranslationFileContents[lang] + if !ok { + contents, err = repo.readTranslationFile(repo.NotificationDir, fmt.Sprintf("/i18n/%s.yaml", lang)) + if caos_errs.IsNotFound(err) { + contents, err = repo.readTranslationFile(repo.NotificationDir, fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) + } + if err != nil { + return nil, err + } + repo.NotificationTranslationFileContents[lang] = contents } - return iam_es_model.MessageTextsViewToModel(text, true), err + messageTexts := new(domain.MessageTexts) + if err := yaml.Unmarshal(contents, messageTexts); err != nil { + return nil, caos_errs.ThrowInternal(err, "TEXT-3N9fs", "Errors.TranslationFile.ReadError") + } + return messageTexts.GetMessageTextByType(textType), nil } -func (repo *IAMRepository) GetDefaultMessageText(ctx context.Context, textType, lang string) (*iam_model.MessageTextView, error) { - text, err := repo.View.MessageTextByIDs(repo.SystemDefaults.IamID, textType, lang) +func (repo *IAMRepository) GetCustomMessageText(ctx context.Context, textType, lang string) (*domain.CustomMessageText, error) { + texts, err := repo.View.CustomTextsByAggregateIDAndTemplateAndLand(repo.SystemDefaults.IamID, textType, lang) if err != nil { return nil, err } - text.Default = true - return iam_es_model.MessageTextViewToModel(text), err + result := iam_es_model.CustomTextViewsToMessageDomain(repo.SystemDefaults.IamID, lang, texts) + result.Default = true + return result, err } func (repo *IAMRepository) GetDefaultPrivacyPolicy(ctx context.Context) (*iam_model.PrivacyPolicyView, error) { @@ -412,17 +444,17 @@ func (repo *IAMRepository) GetDefaultPrivacyPolicy(ctx context.Context) (*iam_mo func (repo *IAMRepository) GetDefaultLoginTexts(ctx context.Context, lang string) (*domain.CustomLoginText, error) { repo.mutex.Lock() defer repo.mutex.Unlock() - contents, ok := repo.TranslationFileContents[lang] + contents, ok := repo.LoginTranslationFileContents[lang] var err error if !ok { - contents, err = repo.readTranslationFile(fmt.Sprintf("/i18n/%s.yaml", lang)) + contents, err = repo.readTranslationFile(repo.LoginDir, fmt.Sprintf("/i18n/%s.yaml", lang)) if caos_errs.IsNotFound(err) { - contents, err = repo.readTranslationFile(fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) + contents, err = repo.readTranslationFile(repo.LoginDir, fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) } if err != nil { return nil, err } - repo.TranslationFileContents[lang] = contents + repo.LoginTranslationFileContents[lang] = contents } loginText := new(domain.CustomLoginText) if err := yaml.Unmarshal(contents, loginText); err != nil { @@ -447,8 +479,8 @@ func (repo *IAMRepository) getIAMEvents(ctx context.Context, sequence uint64) ([ return repo.Eventstore.FilterEvents(ctx, query) } -func (repo *IAMRepository) readTranslationFile(filename string) ([]byte, error) { - r, err := repo.LoginDir.Open(filename) +func (repo *IAMRepository) readTranslationFile(dir http.FileSystem, filename string) ([]byte, error) { + r, err := dir.Open(filename) if os.IsNotExist(err) { return nil, caos_errs.ThrowNotFound(err, "TEXT-3n9fs", "Errors.TranslationFile.NotFound") } diff --git a/internal/admin/repository/eventsourcing/repository.go b/internal/admin/repository/eventsourcing/repository.go index 933148cb16..c956507300 100644 --- a/internal/admin/repository/eventsourcing/repository.go +++ b/internal/admin/repository/eventsourcing/repository.go @@ -54,6 +54,9 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, s statikLoginFS, err := fs.NewWithNamespace("login") logging.Log("CONFI-7usEW").OnError(err).Panic("unable to start login statik dir") + statikNotificationFS, err := fs.NewWithNamespace("notification") + logging.Log("CONFI-7usEW").OnError(err).Panic("unable to start notification statik dir") + return &EsRepository{ spooler: spool, OrgRepo: eventstore.OrgRepo{ @@ -63,14 +66,16 @@ func Start(ctx context.Context, conf Config, systemDefaults sd.SystemDefaults, s SystemDefaults: systemDefaults, }, IAMRepository: eventstore.IAMRepository{ - Eventstore: es, - View: view, - SystemDefaults: systemDefaults, - SearchLimit: conf.SearchLimit, - Roles: roles, - PrefixAvatarURL: assetsAPI, - LoginDir: statikLoginFS, - TranslationFileContents: make(map[string][]byte), + Eventstore: es, + View: view, + SystemDefaults: systemDefaults, + SearchLimit: conf.SearchLimit, + Roles: roles, + PrefixAvatarURL: assetsAPI, + LoginDir: statikLoginFS, + NotificationDir: statikNotificationFS, + LoginTranslationFileContents: make(map[string][]byte), + NotificationTranslationFileContents: make(map[string][]byte), }, AdministratorRepo: eventstore.AdministratorRepo{ View: view, diff --git a/internal/admin/repository/iam.go b/internal/admin/repository/iam.go index 781a53ae7c..8523cc18c2 100644 --- a/internal/admin/repository/iam.go +++ b/internal/admin/repository/iam.go @@ -2,6 +2,9 @@ package repository import ( "context" + + "golang.org/x/text/language" + "github.com/caos/zitadel/internal/domain" usr_model "github.com/caos/zitadel/internal/user/model" @@ -9,6 +12,8 @@ import ( ) type IAMRepository interface { + Languages(ctx context.Context) ([]language.Tag, error) + SearchIAMMembers(ctx context.Context, request *iam_model.IAMMemberSearchRequest) (*iam_model.IAMMemberSearchResponse, error) GetIAMMemberRoles() []string @@ -29,8 +34,8 @@ type IAMRepository interface { GetDefaultMailTemplate(ctx context.Context) (*iam_model.MailTemplateView, error) - GetDefaultMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) - GetDefaultMessageText(ctx context.Context, textType string, language string) (*iam_model.MessageTextView, error) + GetDefaultMessageText(ctx context.Context, textType, language string) (*domain.CustomMessageText, error) + GetCustomMessageText(ctx context.Context, textType string, language string) (*domain.CustomMessageText, error) GetDefaultLoginTexts(ctx context.Context, lang string) (*domain.CustomLoginText, error) GetCustomLoginTexts(ctx context.Context, lang string) (*domain.CustomLoginText, error) diff --git a/internal/api/grpc/admin/custom_text.go b/internal/api/grpc/admin/custom_text.go index e765ce3931..aa61f4b3e1 100644 --- a/internal/api/grpc/admin/custom_text.go +++ b/internal/api/grpc/admin/custom_text.go @@ -15,7 +15,17 @@ func (s *Server) GetDefaultInitMessageText(ctx context.Context, req *admin_pb.Ge return nil, err } return &admin_pb.GetDefaultInitMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomInitMessageText(ctx context.Context, req *admin_pb.GetCustomInitMessageTextRequest) (*admin_pb.GetCustomInitMessageTextResponse, error) { + msg, err := s.iam.GetCustomMessageText(ctx, domain.InitCodeMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomInitMessageTextResponse{ + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -39,7 +49,17 @@ func (s *Server) GetDefaultPasswordResetMessageText(ctx context.Context, req *ad return nil, err } return &admin_pb.GetDefaultPasswordResetMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomPasswordResetMessageText(ctx context.Context, req *admin_pb.GetCustomPasswordResetMessageTextRequest) (*admin_pb.GetCustomPasswordResetMessageTextResponse, error) { + msg, err := s.iam.GetCustomMessageText(ctx, domain.PasswordResetMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomPasswordResetMessageTextResponse{ + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -63,7 +83,17 @@ func (s *Server) GetDefaultVerifyEmailMessageText(ctx context.Context, req *admi return nil, err } return &admin_pb.GetDefaultVerifyEmailMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomVerifyEmailMessageText(ctx context.Context, req *admin_pb.GetCustomVerifyEmailMessageTextRequest) (*admin_pb.GetCustomVerifyEmailMessageTextResponse, error) { + msg, err := s.iam.GetCustomMessageText(ctx, domain.VerifyEmailMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomVerifyEmailMessageTextResponse{ + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -87,7 +117,17 @@ func (s *Server) GetDefaultVerifyPhoneMessageText(ctx context.Context, req *admi return nil, err } return &admin_pb.GetDefaultVerifyPhoneMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomVerifyPhoneMessageText(ctx context.Context, req *admin_pb.GetCustomVerifyPhoneMessageTextRequest) (*admin_pb.GetCustomVerifyPhoneMessageTextResponse, error) { + msg, err := s.iam.GetCustomMessageText(ctx, domain.VerifyPhoneMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomVerifyPhoneMessageTextResponse{ + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -111,7 +151,17 @@ func (s *Server) GetDefaultDomainClaimedMessageText(ctx context.Context, req *ad return nil, err } return &admin_pb.GetDefaultDomainClaimedMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), + }, nil +} + +func (s *Server) GetCustomDomainClaimedMessageText(ctx context.Context, req *admin_pb.GetCustomDomainClaimedMessageTextRequest) (*admin_pb.GetCustomDomainClaimedMessageTextResponse, error) { + msg, err := s.iam.GetCustomMessageText(ctx, domain.DomainClaimedMessageType, req.Language) + if err != nil { + return nil, err + } + return &admin_pb.GetCustomDomainClaimedMessageTextResponse{ + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } diff --git a/internal/api/grpc/admin/language.go b/internal/api/grpc/admin/language.go new file mode 100644 index 0000000000..9257bed7ef --- /dev/null +++ b/internal/api/grpc/admin/language.go @@ -0,0 +1,16 @@ +package admin + +import ( + "context" + + "github.com/caos/zitadel/internal/api/grpc/text" + admin_pb "github.com/caos/zitadel/pkg/grpc/admin" +) + +func (s *Server) GetSupportedLanguages(ctx context.Context, req *admin_pb.GetSupportedLanguagesRequest) (*admin_pb.GetSupportedLanguagesResponse, error) { + langs, err := s.iam.Languages(ctx) + if err != nil { + return nil, err + } + return &admin_pb.GetSupportedLanguagesResponse{Languages: text.LanguageTagsToStrings(langs)}, nil +} diff --git a/internal/api/grpc/auth/language.go b/internal/api/grpc/auth/language.go new file mode 100644 index 0000000000..f794d4c9ea --- /dev/null +++ b/internal/api/grpc/auth/language.go @@ -0,0 +1,16 @@ +package auth + +import ( + "context" + + "github.com/caos/zitadel/internal/api/grpc/text" + auth_pb "github.com/caos/zitadel/pkg/grpc/auth" +) + +func (s *Server) GetSupportedLanguages(ctx context.Context, req *auth_pb.GetSupportedLanguagesRequest) (*auth_pb.GetSupportedLanguagesResponse, error) { + langs, err := s.repo.Languages(ctx) + if err != nil { + return nil, err + } + return &auth_pb.GetSupportedLanguagesResponse{Languages: text.LanguageTagsToStrings(langs)}, nil +} diff --git a/internal/api/grpc/management/custom_text.go b/internal/api/grpc/management/custom_text.go index 0d2c4d8dac..805292149f 100644 --- a/internal/api/grpc/management/custom_text.go +++ b/internal/api/grpc/management/custom_text.go @@ -18,7 +18,7 @@ func (s *Server) GetCustomInitMessageText(ctx context.Context, req *mgmt_pb.GetC return nil, err } return &mgmt_pb.GetCustomInitMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -28,7 +28,7 @@ func (s *Server) GetDefaultInitMessageText(ctx context.Context, req *mgmt_pb.Get return nil, err } return &mgmt_pb.GetDefaultInitMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -66,7 +66,7 @@ func (s *Server) GetCustomPasswordResetMessageText(ctx context.Context, req *mgm return nil, err } return &mgmt_pb.GetCustomPasswordResetMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -76,7 +76,7 @@ func (s *Server) GetDefaultPasswordResetMessageText(ctx context.Context, req *mg return nil, err } return &mgmt_pb.GetDefaultPasswordResetMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -114,7 +114,7 @@ func (s *Server) GetCustomVerifyEmailMessageText(ctx context.Context, req *mgmt_ return nil, err } return &mgmt_pb.GetCustomVerifyEmailMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -124,7 +124,7 @@ func (s *Server) GetDefaultVerifyEmailMessageText(ctx context.Context, req *mgmt return nil, err } return &mgmt_pb.GetDefaultVerifyEmailMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -162,7 +162,7 @@ func (s *Server) GetCustomVerifyPhoneMessageText(ctx context.Context, req *mgmt_ return nil, err } return &mgmt_pb.GetCustomVerifyPhoneMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -172,7 +172,7 @@ func (s *Server) GetDefaultVerifyPhoneMessageText(ctx context.Context, req *mgmt return nil, err } return &mgmt_pb.GetDefaultVerifyPhoneMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -210,7 +210,7 @@ func (s *Server) GetCustomDomainClaimedMessageText(ctx context.Context, req *mgm return nil, err } return &mgmt_pb.GetCustomDomainClaimedMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } @@ -220,7 +220,7 @@ func (s *Server) GetDefaultDomainClaimedMessageText(ctx context.Context, req *mg return nil, err } return &mgmt_pb.GetDefaultDomainClaimedMessageTextResponse{ - CustomText: text_grpc.ModelCustomMsgTextToPb(msg), + CustomText: text_grpc.DomainCustomMsgTextToPb(msg), }, nil } diff --git a/internal/api/grpc/management/language.go b/internal/api/grpc/management/language.go new file mode 100644 index 0000000000..68327bfd14 --- /dev/null +++ b/internal/api/grpc/management/language.go @@ -0,0 +1,16 @@ +package management + +import ( + "context" + + "github.com/caos/zitadel/internal/api/grpc/text" + mgmt_pb "github.com/caos/zitadel/pkg/grpc/management" +) + +func (s *Server) GetSupportedLanguages(ctx context.Context, req *mgmt_pb.GetSupportedLanguagesRequest) (*mgmt_pb.GetSupportedLanguagesResponse, error) { + langs, err := s.org.Languages(ctx) + if err != nil { + return nil, err + } + return &mgmt_pb.GetSupportedLanguagesResponse{Languages: text.LanguageTagsToStrings(langs)}, nil +} diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go index 600702e5d7..e2ffb2c5f8 100644 --- a/internal/api/grpc/management/user_converter.go +++ b/internal/api/grpc/management/user_converter.go @@ -43,7 +43,7 @@ func AddHumanUserRequestToDomain(req *mgmt_pb.AddHumanUserRequest) *domain.Human Username: req.UserName, } preferredLanguage, err := language.Parse(req.Profile.PreferredLanguage) - logging.Log("MANAG-3GUFJ").OnError(err).Debug("language malformed") + logging.Log("MANAG-M029f").OnError(err).Debug("language malformed") h.Profile = &domain.Profile{ FirstName: req.Profile.FirstName, LastName: req.Profile.LastName, diff --git a/internal/api/grpc/text/custom_text.go b/internal/api/grpc/text/custom_text.go index 9763d9d740..3e25c98ac1 100644 --- a/internal/api/grpc/text/custom_text.go +++ b/internal/api/grpc/text/custom_text.go @@ -3,11 +3,10 @@ package text import ( "github.com/caos/zitadel/internal/api/grpc/object" "github.com/caos/zitadel/internal/domain" - "github.com/caos/zitadel/internal/iam/model" text_pb "github.com/caos/zitadel/pkg/grpc/text" ) -func ModelCustomMsgTextToPb(msg *model.MessageTextView) *text_pb.MessageCustomText { +func DomainCustomMsgTextToPb(msg *domain.CustomMessageText) *text_pb.MessageCustomText { return &text_pb.MessageCustomText{ Title: msg.Title, PreHeader: msg.PreHeader, diff --git a/internal/api/grpc/text/language.go b/internal/api/grpc/text/language.go new file mode 100644 index 0000000000..9ae5b1ed1a --- /dev/null +++ b/internal/api/grpc/text/language.go @@ -0,0 +1,13 @@ +package text + +import ( + "golang.org/x/text/language" +) + +func LanguageTagsToStrings(langs []language.Tag) []string { + result := make([]string, len(langs)) + for i, lang := range langs { + result[i] = lang.String() + } + return result +} diff --git a/internal/auth/repository/eventsourcing/eventstore/iam.go b/internal/auth/repository/eventsourcing/eventstore/iam.go index 3fd493e4ae..b5d37c705b 100644 --- a/internal/auth/repository/eventsourcing/eventstore/iam.go +++ b/internal/auth/repository/eventsourcing/eventstore/iam.go @@ -2,15 +2,35 @@ package eventstore import ( "context" + "net/http" + + "github.com/caos/logging" + "golang.org/x/text/language" + + "github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/query" "github.com/caos/zitadel/internal/iam/model" ) type IAMRepository struct { - IAMID string + IAMID string + LoginDir http.FileSystem IAMV2QuerySide *query.Queries + supportedLangs []language.Tag +} + +func (repo *IAMRepository) Languages(ctx context.Context) ([]language.Tag, error) { + if len(repo.supportedLangs) == 0 { + langs, err := i18n.SupportedLanguages(repo.LoginDir) + if err != nil { + logging.Log("ADMIN-tiMWs").WithError(err).Debug("unable to parse language") + return nil, err + } + repo.supportedLangs = langs + } + return repo.supportedLangs, nil } func (repo *IAMRepository) GetIAM(ctx context.Context) (*model.IAM, error) { diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 0373c2fedf..4c75bc7146 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -3,6 +3,9 @@ package eventsourcing import ( "context" + "github.com/caos/logging" + "github.com/rakyll/statik/fs" + "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/auth/repository/eventsourcing/eventstore" "github.com/caos/zitadel/internal/auth/repository/eventsourcing/spooler" @@ -76,6 +79,9 @@ func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, co return nil, err } + statikLoginFS, err := fs.NewWithNamespace("login") + logging.Log("CONFI-20opp").OnError(err).Panic("unable to start login statik dir") + keyChan := make(chan *key_model.KeyView) spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, systemDefaults, keyChan) locker := spooler.NewLocker(sqlClient) @@ -153,6 +159,7 @@ func Start(conf Config, authZ authz.Config, systemDefaults sd.SystemDefaults, co }, eventstore.IAMRepository{ IAMID: systemDefaults.IamID, + LoginDir: statikLoginFS, IAMV2QuerySide: queries, }, eventstore.FeaturesRepo{ diff --git a/internal/auth/repository/iam.go b/internal/auth/repository/iam.go index 6d7e9efd67..fb0ba1885f 100644 --- a/internal/auth/repository/iam.go +++ b/internal/auth/repository/iam.go @@ -3,9 +3,12 @@ package repository import ( "context" + "golang.org/x/text/language" + "github.com/caos/zitadel/internal/iam/model" ) type IAMRepository interface { + Languages(ctx context.Context) ([]language.Tag, error) GetIAM(ctx context.Context) (*model.IAM, error) } diff --git a/internal/domain/custom_messge_text.go b/internal/domain/custom_message_text.go similarity index 64% rename from internal/domain/custom_messge_text.go rename to internal/domain/custom_message_text.go index 136bfd836f..ed457d2aaf 100644 --- a/internal/domain/custom_messge_text.go +++ b/internal/domain/custom_message_text.go @@ -21,6 +21,14 @@ const ( MessageFooterText = "Footer" ) +type MessageTexts struct { + InitCode CustomMessageText + PasswordReset CustomMessageText + VerifyEmail CustomMessageText + VerifyPhone CustomMessageText + DomainClaimed CustomMessageText +} + type CustomMessageText struct { models.ObjectRoot @@ -40,3 +48,19 @@ type CustomMessageText struct { func (m *CustomMessageText) IsValid() bool { return m.MessageTextType != "" && m.Language != language.Und } + +func (m *MessageTexts) GetMessageTextByType(msgType string) *CustomMessageText { + switch msgType { + case InitCodeMessageType: + return &m.InitCode + case PasswordResetMessageType: + return &m.PasswordReset + case VerifyEmailMessageType: + return &m.VerifyEmail + case VerifyPhoneMessageType: + return &m.VerifyPhone + case DomainClaimedMessageType: + return &m.DomainClaimed + } + return nil +} diff --git a/internal/iam/model/message_text_view.go b/internal/iam/model/message_text_view.go index 86b42cc7a7..1348d0d96e 100644 --- a/internal/iam/model/message_text_view.go +++ b/internal/iam/model/message_text_view.go @@ -8,10 +8,6 @@ import ( "github.com/caos/zitadel/internal/domain" ) -type MessageTextsView struct { - Texts []*MessageTextView - Default bool -} type MessageTextView struct { AggregateID string MessageTextType string diff --git a/internal/iam/repository/view/model/custom_text.go b/internal/iam/repository/view/model/custom_text.go index b518c82bda..5c5f72b6fe 100644 --- a/internal/iam/repository/view/model/custom_text.go +++ b/internal/iam/repository/view/model/custom_text.go @@ -110,6 +110,46 @@ func (r *CustomTextView) IsMessageTemplate() bool { r.Template == domain.DomainClaimedMessageType } +func CustomTextViewsToMessageDomain(aggregateID, lang string, texts []*CustomTextView) *domain.CustomMessageText { + langTag := language.Make(lang) + result := &domain.CustomMessageText{ + ObjectRoot: models.ObjectRoot{ + AggregateID: aggregateID, + }, + Language: langTag, + } + for _, text := range texts { + if text.CreationDate.Before(result.CreationDate) { + result.CreationDate = text.CreationDate + } + if text.ChangeDate.After(result.ChangeDate) { + result.ChangeDate = text.ChangeDate + } + if text.Key == domain.MessageTitle { + result.Title = text.Text + } + if text.Key == domain.MessagePreHeader { + result.PreHeader = text.Text + } + if text.Key == domain.MessageSubject { + result.Subject = text.Text + } + if text.Key == domain.MessageGreeting { + result.Greeting = text.Text + } + if text.Key == domain.MessageText { + result.Text = text.Text + } + if text.Key == domain.MessageButtonText { + result.ButtonText = text.Text + } + if text.Key == domain.MessageFooterText { + result.FooterText = text.Text + } + } + return result +} + func CustomTextViewsToLoginDomain(aggregateID, lang string, texts []*CustomTextView) *domain.CustomLoginText { langTag := language.Make(lang) result := &domain.CustomLoginText{ diff --git a/internal/iam/repository/view/model/message_text.go b/internal/iam/repository/view/model/message_text.go index b5ec8c90dc..3e096bda7e 100644 --- a/internal/iam/repository/view/model/message_text.go +++ b/internal/iam/repository/view/model/message_text.go @@ -59,12 +59,6 @@ func MessageTextViewFromModel(template *model.MessageTextView) *MessageTextView } } -func MessageTextsViewToModel(textsIn []*MessageTextView, defaultIn bool) *model.MessageTextsView { - return &model.MessageTextsView{ - Texts: messageTextsViewToModelArr(textsIn, defaultIn), - } -} - func messageTextsViewToModelArr(texts []*MessageTextView, defaultIn bool) []*model.MessageTextView { result := make([]*model.MessageTextView, len(texts)) for i, r := range texts { diff --git a/internal/management/repository/eventsourcing/eventstore/org.go b/internal/management/repository/eventsourcing/eventstore/org.go index 099977c3c6..d4e5b86010 100644 --- a/internal/management/repository/eventsourcing/eventstore/org.go +++ b/internal/management/repository/eventsourcing/eventstore/org.go @@ -14,6 +14,7 @@ import ( "github.com/caos/logging" "github.com/ghodss/yaml" "github.com/golang/protobuf/ptypes" + "golang.org/x/text/language" "github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/config/systemdefaults" @@ -21,6 +22,7 @@ import ( "github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/eventstore/v1" "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" iam_view "github.com/caos/zitadel/internal/iam/repository/view" iam_es_model "github.com/caos/zitadel/internal/iam/repository/view/model" @@ -37,15 +39,30 @@ import ( ) type OrgRepository struct { - SearchLimit uint64 - Eventstore v1.Eventstore - View *mgmt_view.View - Roles []string - SystemDefaults systemdefaults.SystemDefaults - PrefixAvatarURL string - LoginDir http.FileSystem - TranslationFileContents map[string][]byte - mutex sync.Mutex + SearchLimit uint64 + Eventstore v1.Eventstore + View *mgmt_view.View + Roles []string + SystemDefaults systemdefaults.SystemDefaults + PrefixAvatarURL string + LoginDir http.FileSystem + NotificationDir http.FileSystem + LoginTranslationFileContents map[string][]byte + NotificationTranslationFileContents map[string][]byte + mutex sync.Mutex + supportedLangs []language.Tag +} + +func (repo *OrgRepository) Languages(ctx context.Context) ([]language.Tag, error) { + if len(repo.supportedLangs) == 0 { + langs, err := i18n.SupportedLanguages(repo.LoginDir) + if err != nil { + logging.Log("ADMIN-tiMWs").WithError(err).Debug("unable to parse language") + return nil, err + } + repo.supportedLangs = langs + } + return repo.supportedLangs, nil } func (repo *OrgRepository) OrgByID(ctx context.Context, id string) (*org_model.OrgView, error) { @@ -576,68 +593,74 @@ func (repo *OrgRepository) GetMailTemplate(ctx context.Context) (*iam_model.Mail return iam_es_model.MailTemplateViewToModel(template), err } -func (repo *OrgRepository) GetDefaultMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) { - texts, err := repo.View.MessageTextsByAggregateID(repo.SystemDefaults.IamID) - if err != nil { - return nil, err - } - return iam_es_model.MessageTextsViewToModel(texts, true), err -} - -func (repo *OrgRepository) GetMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) { - defaultIn := false - texts, err := repo.View.MessageTextsByAggregateID(authz.GetCtxData(ctx).OrgID) - if errors.IsNotFound(err) || len(texts) == 0 { - texts, err = repo.View.MessageTextsByAggregateID(repo.SystemDefaults.IamID) +func (repo *OrgRepository) GetDefaultMessageText(ctx context.Context, textType, lang string) (*domain.CustomMessageText, error) { + repo.mutex.Lock() + defer repo.mutex.Unlock() + var err error + contents, ok := repo.NotificationTranslationFileContents[lang] + if !ok { + contents, err = repo.readTranslationFile(repo.NotificationDir, fmt.Sprintf("/i18n/%s.yaml", lang)) + if errors.IsNotFound(err) { + contents, err = repo.readTranslationFile(repo.NotificationDir, fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) + } if err != nil { return nil, err } - defaultIn = true + repo.NotificationTranslationFileContents[lang] = contents } + notificationTextMap := make(map[string]interface{}) + if err := yaml.Unmarshal(contents, ¬ificationTextMap); err != nil { + return nil, errors.ThrowInternal(err, "TEXT-093sd", "Errors.TranslationFile.ReadError") + } + texts, err := repo.View.CustomTextsByAggregateIDAndTemplateAndLand(repo.SystemDefaults.IamID, textType, lang) if err != nil { return nil, err } - return iam_es_model.MessageTextsViewToModel(texts, defaultIn), err -} - -func (repo *OrgRepository) GetDefaultMessageText(ctx context.Context, textType, lang string) (*iam_model.MessageTextView, error) { - text, err := repo.View.MessageTextByIDs(repo.SystemDefaults.IamID, textType, lang) - if err != nil { - return nil, err - } - text.Default = true - return iam_es_model.MessageTextViewToModel(text), err -} - -func (repo *OrgRepository) GetMessageText(ctx context.Context, orgID, textType, lang string) (*iam_model.MessageTextView, error) { - text, err := repo.View.MessageTextByIDs(orgID, textType, lang) - if errors.IsNotFound(err) { - result, err := repo.GetDefaultMessageText(ctx, textType, lang) - if err != nil { - return nil, err + for _, text := range texts { + messageTextMap, ok := notificationTextMap[textType].(map[string]interface{}) + if !ok { + continue } - return result, nil + messageTextMap[text.Key] = text.Text } + jsonbody, err := json.Marshal(notificationTextMap) + if err != nil { + return nil, errors.ThrowInternal(err, "TEXT-02m8f", "Errors.TranslationFile.MergeError") + } + notificationText := new(domain.MessageTexts) + if err := json.Unmarshal(jsonbody, ¬ificationText); err != nil { + return nil, errors.ThrowInternal(err, "TEXT-20ops", "Errors.TranslationFile.MergeError") + } + result := notificationText.GetMessageTextByType(textType) + result.Default = true + return result, nil +} + +func (repo *OrgRepository) GetMessageText(ctx context.Context, orgID, textType, lang string) (*domain.CustomMessageText, error) { + texts, err := repo.View.CustomTextsByAggregateIDAndTemplateAndLand(orgID, textType, lang) if err != nil { return nil, err } - return iam_es_model.MessageTextViewToModel(text), err + if len(texts) == 0 { + return repo.GetDefaultMessageText(ctx, textType, lang) + } + return iam_es_model.CustomTextViewsToMessageDomain(repo.SystemDefaults.IamID, lang, texts), err } func (repo *OrgRepository) GetDefaultLoginTexts(ctx context.Context, lang string) (*domain.CustomLoginText, error) { repo.mutex.Lock() defer repo.mutex.Unlock() - contents, ok := repo.TranslationFileContents[lang] + contents, ok := repo.LoginTranslationFileContents[lang] var err error if !ok { - contents, err = repo.readTranslationFile(fmt.Sprintf("/i18n/%s.yaml", lang)) + contents, err = repo.readTranslationFile(repo.LoginDir, fmt.Sprintf("/i18n/%s.yaml", lang)) if errors.IsNotFound(err) { - contents, err = repo.readTranslationFile(fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) + contents, err = repo.readTranslationFile(repo.LoginDir, fmt.Sprintf("/i18n/%s.yaml", repo.SystemDefaults.DefaultLanguage.String())) } if err != nil { return nil, err } - repo.TranslationFileContents[lang] = contents + repo.LoginTranslationFileContents[lang] = contents } loginTextMap := make(map[string]interface{}) if err := yaml.Unmarshal(contents, &loginTextMap); err != nil { @@ -770,8 +793,8 @@ func (repo *OrgRepository) getIAMEvents(ctx context.Context, sequence uint64) ([ return repo.Eventstore.FilterEvents(ctx, query) } -func (repo *OrgRepository) readTranslationFile(filename string) ([]byte, error) { - r, err := repo.LoginDir.Open(filename) +func (repo *OrgRepository) readTranslationFile(dir http.FileSystem, filename string) ([]byte, error) { + r, err := dir.Open(filename) if os.IsNotExist(err) { return nil, errors.ThrowNotFound(err, "TEXT-93nfl", "Errors.TranslationFile.NotFound") } diff --git a/internal/management/repository/eventsourcing/repository.go b/internal/management/repository/eventsourcing/repository.go index cf7b575c99..1a0c38989b 100644 --- a/internal/management/repository/eventsourcing/repository.go +++ b/internal/management/repository/eventsourcing/repository.go @@ -58,17 +58,22 @@ func Start(conf Config, systemDefaults sd.SystemDefaults, roles []string, querie statikLoginFS, err := fs.NewWithNamespace("login") logging.Log("CONFI-7usEW").OnError(err).Panic("unable to start login statik dir") + statikNotificationFS, err := fs.NewWithNamespace("notification") + logging.Log("CONFI-7usEW").OnError(err).Panic("unable to start notification statik dir") + return &EsRepository{ spooler: spool, OrgRepository: eventstore.OrgRepository{ - SearchLimit: conf.SearchLimit, - Eventstore: es, - View: view, - Roles: roles, - SystemDefaults: systemDefaults, - PrefixAvatarURL: assetsAPI, - LoginDir: statikLoginFS, - TranslationFileContents: make(map[string][]byte), + SearchLimit: conf.SearchLimit, + Eventstore: es, + View: view, + Roles: roles, + SystemDefaults: systemDefaults, + PrefixAvatarURL: assetsAPI, + LoginDir: statikLoginFS, + NotificationDir: statikNotificationFS, + LoginTranslationFileContents: make(map[string][]byte), + NotificationTranslationFileContents: make(map[string][]byte), }, ProjectRepo: eventstore.ProjectRepo{es, conf.SearchLimit, view, roles, systemDefaults.IamID, assetsAPI}, UserRepo: eventstore.UserRepo{es, conf.SearchLimit, view, systemDefaults, assetsAPI}, diff --git a/internal/management/repository/org.go b/internal/management/repository/org.go index de8594aa77..f0c683ae41 100644 --- a/internal/management/repository/org.go +++ b/internal/management/repository/org.go @@ -4,6 +4,8 @@ import ( "context" "time" + "golang.org/x/text/language" + "github.com/caos/zitadel/internal/domain" iam_model "github.com/caos/zitadel/internal/iam/model" @@ -11,6 +13,7 @@ import ( ) type OrgRepository interface { + Languages(ctx context.Context) ([]language.Tag, error) OrgByID(ctx context.Context, id string) (*org_model.OrgView, error) OrgByDomainGlobal(ctx context.Context, domain string) (*org_model.OrgView, error) OrgChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, auditLogRetention time.Duration) (*org_model.OrgChanges, error) @@ -48,10 +51,8 @@ type OrgRepository interface { GetDefaultMailTemplate(ctx context.Context) (*iam_model.MailTemplateView, error) GetMailTemplate(ctx context.Context) (*iam_model.MailTemplateView, error) - GetDefaultMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) - GetMessageTexts(ctx context.Context) (*iam_model.MessageTextsView, error) - GetDefaultMessageText(ctx context.Context, textType string, language string) (*iam_model.MessageTextView, error) - GetMessageText(ctx context.Context, orgID, textType, language string) (*iam_model.MessageTextView, error) + GetDefaultMessageText(ctx context.Context, textType string, language string) (*domain.CustomMessageText, error) + GetMessageText(ctx context.Context, orgID, textType, lang string) (*domain.CustomMessageText, error) GetDefaultLoginTexts(ctx context.Context, lang string) (*domain.CustomLoginText, error) GetLoginTexts(ctx context.Context, orgID, lang string) (*domain.CustomLoginText, error) diff --git a/internal/notification/repository/eventsourcing/handler/custom_text.go b/internal/notification/repository/eventsourcing/handler/custom_text.go new file mode 100644 index 0000000000..90d05fe1ee --- /dev/null +++ b/internal/notification/repository/eventsourcing/handler/custom_text.go @@ -0,0 +1,125 @@ +package handler + +import ( + "github.com/caos/logging" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1" + es_models "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/eventstore/v1/query" + "github.com/caos/zitadel/internal/eventstore/v1/spooler" + iam_es_model "github.com/caos/zitadel/internal/iam/repository/eventsourcing/model" + iam_model "github.com/caos/zitadel/internal/iam/repository/view/model" + "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" +) + +type CustomText struct { + handler + subscription *v1.Subscription +} + +func newCustomText(handler handler) *CustomText { + h := &CustomText{ + handler: handler, + } + + h.subscribe() + + return h +} + +func (m *CustomText) subscribe() { + m.subscription = m.es.Subscribe(m.AggregateTypes()...) + go func() { + for event := range m.subscription.Events { + query.ReduceEvent(m, event) + } + }() +} + +const ( + customTextTable = "notification.custom_texts" +) + +func (m *CustomText) ViewModel() string { + return customTextTable +} + +func (m *CustomText) Subscription() *v1.Subscription { + return m.subscription +} + +func (_ *CustomText) AggregateTypes() []es_models.AggregateType { + return []es_models.AggregateType{model.OrgAggregate, iam_es_model.IAMAggregate} +} + +func (p *CustomText) CurrentSequence() (uint64, error) { + sequence, err := p.view.GetLatestCustomTextSequence() + if err != nil { + return 0, err + } + return sequence.CurrentSequence, nil +} + +func (m *CustomText) EventQuery() (*es_models.SearchQuery, error) { + sequence, err := m.view.GetLatestCustomTextSequence() + if err != nil { + return nil, err + } + return es_models.NewSearchQuery(). + AggregateTypeFilter(m.AggregateTypes()...). + LatestSequenceFilter(sequence.CurrentSequence), nil +} + +func (m *CustomText) Reduce(event *es_models.Event) (err error) { + switch event.AggregateType { + case model.OrgAggregate, iam_es_model.IAMAggregate: + err = m.processCustomText(event) + } + return err +} + +func (m *CustomText) processCustomText(event *es_models.Event) (err error) { + customText := new(iam_model.CustomTextView) + switch event.Type { + case iam_es_model.CustomTextSet, model.CustomTextSet: + text := new(iam_model.CustomTextView) + err = text.SetData(event) + if err != nil { + return err + } + customText, err = m.view.CustomTextByIDs(event.AggregateID, text.Template, text.Key, text.Language) + if err != nil && !caos_errs.IsNotFound(err) { + return err + } + if caos_errs.IsNotFound(err) { + err = nil + customText = new(iam_model.CustomTextView) + customText.Language = text.Language + customText.Template = text.Template + customText.CreationDate = event.CreationDate + } + err = customText.AppendEvent(event) + case iam_es_model.CustomTextRemoved, model.CustomTextRemoved: + text := new(iam_model.CustomTextView) + err = text.SetData(event) + if err != nil { + return err + } + return m.view.DeleteCustomText(event.AggregateID, text.Template, text.Language, event) + default: + return m.view.ProcessedCustomTextSequence(event) + } + if err != nil { + return err + } + return m.view.PutCustomText(customText, event) +} + +func (m *CustomText) OnError(event *es_models.Event, err error) error { + logging.LogWithFields("SPOOL-3m912", "id", event.AggregateID).WithError(err).Warn("something went wrong in custom text handler") + return spooler.HandleError(event, err, m.view.GetLatestCustomTextFailedEvent, m.view.ProcessedCustomTextFailedEvent, m.view.ProcessedCustomTextSequence, m.errorCountUntilSkip) +} + +func (o *CustomText) OnSuccess() error { + return spooler.HandleSuccess(o.view.UpdateCustomTextSpoolerRunTimestamp) +} diff --git a/internal/notification/repository/eventsourcing/handler/handler.go b/internal/notification/repository/eventsourcing/handler/handler.go index 310993bfed..5683b72f86 100644 --- a/internal/notification/repository/eventsourcing/handler/handler.go +++ b/internal/notification/repository/eventsourcing/handler/handler.go @@ -1,17 +1,18 @@ package handler import ( - "github.com/caos/zitadel/internal/command" - "github.com/caos/zitadel/internal/eventstore/v1" "net/http" "time" + "github.com/caos/zitadel/internal/command" + "github.com/caos/zitadel/internal/eventstore/v1" + "github.com/caos/logging" + sd "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/config/types" "github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/eventstore/v1/query" - "github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/notification/repository/eventsourcing/view" ) @@ -34,7 +35,7 @@ func (h *handler) Eventstore() v1.Eventstore { return h.es } -func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es v1.Eventstore, command *command.Commands, systemDefaults sd.SystemDefaults, i18n *i18n.Translator, dir http.FileSystem, apiDomain string) []query.Handler { +func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es v1.Eventstore, command *command.Commands, systemDefaults sd.SystemDefaults, dir http.FileSystem, apiDomain string) []query.Handler { aesCrypto, err := crypto.NewAESCrypto(systemDefaults.UserVerificationKey) if err != nil { logging.Log("HANDL-s90ew").WithError(err).Debug("error create new aes crypto") @@ -44,12 +45,12 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es handler{view, bulkLimit, configs.cycleDuration("User"), errorCount, es}, systemDefaults.IamID, ), + newCustomText(handler{view, bulkLimit, configs.cycleDuration("CustomText"), errorCount, es}), newNotification( handler{view, bulkLimit, configs.cycleDuration("Notification"), errorCount, es}, command, systemDefaults, aesCrypto, - i18n, dir, apiDomain, ), diff --git a/internal/notification/repository/eventsourcing/handler/notification.go b/internal/notification/repository/eventsourcing/handler/notification.go index aafd1bc3a1..3e3520173a 100644 --- a/internal/notification/repository/eventsourcing/handler/notification.go +++ b/internal/notification/repository/eventsourcing/handler/notification.go @@ -29,19 +29,14 @@ import ( ) const ( - notificationTable = "notification.notifications" - NotifyUserID = "NOTIFICATION" - labelPolicyTableOrg = "management.label_policies" - labelPolicyTableDef = "adminapi.label_policies" - mailTemplateTableOrg = "management.mail_templates" - mailTemplateTableDef = "adminapi.mail_templates" - messageTextTableOrg = "management.message_texts" - messageTextTableDef = "adminapi.message_texts" - messageTextTypeDomainClaimed = "DomainClaimed" - messageTextTypeInitCode = "InitCode" - messageTextTypePasswordReset = "PasswordReset" - messageTextTypeVerifyEmail = "VerifyEmail" - messageTextTypeVerifyPhone = "VerifyPhone" + notificationTable = "notification.notifications" + NotifyUserID = "NOTIFICATION" + labelPolicyTableOrg = "management.label_policies" + labelPolicyTableDef = "adminapi.label_policies" + mailTemplateTableOrg = "management.mail_templates" + mailTemplateTableDef = "adminapi.mail_templates" + messageTextTableOrg = "management.message_texts" + messageTextTableDef = "adminapi.message_texts" ) type Notification struct { @@ -49,7 +44,6 @@ type Notification struct { command *command.Commands systemDefaults sd.SystemDefaults AesCrypto crypto.EncryptionAlgorithm - i18n *i18n.Translator statikDir http.FileSystem subscription *v1.Subscription apiDomain string @@ -60,7 +54,6 @@ func newNotification( command *command.Commands, defaults sd.SystemDefaults, aesCrypto crypto.EncryptionAlgorithm, - translator *i18n.Translator, statikDir http.FileSystem, apiDomain string, ) *Notification { @@ -68,7 +61,6 @@ func newNotification( handler: handler, command: command, systemDefaults: defaults, - i18n: translator, statikDir: statikDir, AesCrypto: aesCrypto, apiDomain: apiDomain, @@ -166,12 +158,12 @@ func (n *Notification) handleInitUserCode(event *models.Event) (err error) { return err } - text, err := n.getMessageText(user, messageTextTypeInitCode, user.PreferredLanguage) + translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.InitCodeMessageType) if err != nil { return err } - err = types.SendUserInitCode(string(template.Template), text, user, initCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) + err = types.SendUserInitCode(string(template.Template), translator, user, initCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) if err != nil { return err } @@ -205,11 +197,11 @@ func (n *Notification) handlePasswordCode(event *models.Event) (err error) { return err } - text, err := n.getMessageText(user, messageTextTypePasswordReset, user.PreferredLanguage) + translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.PasswordResetMessageType) if err != nil { return err } - err = types.SendPasswordCode(string(template.Template), text, user, pwCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) + err = types.SendPasswordCode(string(template.Template), translator, user, pwCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) if err != nil { return err } @@ -243,12 +235,12 @@ func (n *Notification) handleEmailVerificationCode(event *models.Event) (err err return err } - text, err := n.getMessageText(user, messageTextTypeVerifyEmail, user.PreferredLanguage) + translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.VerifyEmailMessageType) if err != nil { return err } - err = types.SendEmailVerificationCode(string(template.Template), text, user, emailCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) + err = types.SendEmailVerificationCode(string(template.Template), translator, user, emailCode, n.systemDefaults, n.AesCrypto, colors, n.apiDomain) if err != nil { return err } @@ -270,11 +262,11 @@ func (n *Notification) handlePhoneVerificationCode(event *models.Event) (err err if err != nil { return err } - text, err := n.getMessageText(user, messageTextTypeVerifyPhone, user.PreferredLanguage) + translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.VerifyPhoneMessageType) if err != nil { return err } - err = types.SendPhoneVerificationCode(text, user, phoneCode, n.systemDefaults, n.AesCrypto) + err = types.SendPhoneVerificationCode(translator, user, phoneCode, n.systemDefaults, n.AesCrypto) if err != nil { return err } @@ -309,11 +301,11 @@ func (n *Notification) handleDomainClaimed(event *models.Event) (err error) { return err } - text, err := n.getMessageText(user, messageTextTypeDomainClaimed, user.PreferredLanguage) + translator, err := n.getTranslatorWithOrgTexts(user.ResourceOwner, domain.DomainClaimedMessageType) if err != nil { return err } - err = types.SendDomainClaimed(string(template.Template), text, user, data["userName"], n.systemDefaults, colors, n.apiDomain) + err = types.SendDomainClaimed(string(template.Template), translator, user, data["userName"], n.systemDefaults, colors, n.apiDomain) if err != nil { return err } @@ -400,6 +392,31 @@ func (n *Notification) getMailTemplate(ctx context.Context) (*iam_model.MailTemp return iam_es_model.MailTemplateViewToModel(template), err } +func (n *Notification) getTranslatorWithOrgTexts(orgID, textType string) (*i18n.Translator, error) { + translator, err := i18n.NewTranslator(n.statikDir, i18n.TranslatorConfig{DefaultLanguage: n.systemDefaults.DefaultLanguage}) + if err != nil { + return nil, err + } + allCustomTexts, err := n.view.CustomTextsByAggregateIDAndTemplate(domain.IAMID, textType) + if err == nil { + return translator, nil + } + customTexts, err := n.view.CustomTextsByAggregateIDAndTemplate(orgID, textType) + if err == nil { + return translator, nil + } + allCustomTexts = append(allCustomTexts, customTexts...) + + for _, text := range allCustomTexts { + msg := i18n.Message{ + ID: text.Key, + Text: text.Text, + } + translator.AddMessages(language.Make(text.Language), msg) + } + return translator, nil +} + // Read organization specific texts func (n *Notification) getMessageText(user *model.NotifyUser, textType, lang string) (*iam_model.MessageTextView, error) { langTag := language.Make(lang) diff --git a/internal/notification/repository/eventsourcing/repository.go b/internal/notification/repository/eventsourcing/repository.go index 22c5689b25..3c9e02d057 100644 --- a/internal/notification/repository/eventsourcing/repository.go +++ b/internal/notification/repository/eventsourcing/repository.go @@ -1,17 +1,18 @@ package eventsourcing import ( + "net/http" + "github.com/caos/zitadel/internal/command" "github.com/caos/zitadel/internal/eventstore/v1" - "net/http" + + "golang.org/x/text/language" sd "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/config/types" es_spol "github.com/caos/zitadel/internal/eventstore/v1/spooler" - "github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/notification/repository/eventsourcing/spooler" noti_view "github.com/caos/zitadel/internal/notification/repository/eventsourcing/view" - "golang.org/x/text/language" ) type Config struct { @@ -41,11 +42,7 @@ func Start(conf Config, dir http.FileSystem, systemDefaults sd.SystemDefaults, c return nil, err } - translator, err := i18n.NewTranslator(dir, i18n.TranslatorConfig{DefaultLanguage: conf.DefaultLanguage}) - if err != nil { - return nil, err - } - spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, command, systemDefaults, translator, dir, apiDomain) + spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, command, systemDefaults, dir, apiDomain) return &EsRepository{ spool, diff --git a/internal/notification/repository/eventsourcing/spooler/spooler.go b/internal/notification/repository/eventsourcing/spooler/spooler.go index 0dcf6f09aa..1ac2abf53c 100644 --- a/internal/notification/repository/eventsourcing/spooler/spooler.go +++ b/internal/notification/repository/eventsourcing/spooler/spooler.go @@ -2,13 +2,13 @@ package spooler import ( "database/sql" + "net/http" + "github.com/caos/zitadel/internal/command" "github.com/caos/zitadel/internal/eventstore/v1" - "net/http" sd "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/eventstore/v1/spooler" - "github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/notification/repository/eventsourcing/handler" "github.com/caos/zitadel/internal/notification/repository/eventsourcing/view" ) @@ -20,12 +20,12 @@ type SpoolerConfig struct { Handlers handler.Configs } -func StartSpooler(c SpoolerConfig, es v1.Eventstore, view *view.View, sql *sql.DB, command *command.Commands, systemDefaults sd.SystemDefaults, i18n *i18n.Translator, dir http.FileSystem, apiDomain string) *spooler.Spooler { +func StartSpooler(c SpoolerConfig, es v1.Eventstore, view *view.View, sql *sql.DB, command *command.Commands, systemDefaults sd.SystemDefaults, dir http.FileSystem, apiDomain string) *spooler.Spooler { spoolerConfig := spooler.Config{ Eventstore: es, Locker: &locker{dbClient: sql}, ConcurrentWorkers: c.ConcurrentWorkers, - ViewHandlers: handler.Register(c.Handlers, c.BulkLimit, c.FailureCountUntilSkip, view, es, command, systemDefaults, i18n, dir, apiDomain), + ViewHandlers: handler.Register(c.Handlers, c.BulkLimit, c.FailureCountUntilSkip, view, es, command, systemDefaults, dir, apiDomain), } spool := spoolerConfig.New() spool.Start() diff --git a/internal/notification/repository/eventsourcing/view/custom_texts.go b/internal/notification/repository/eventsourcing/view/custom_texts.go new file mode 100644 index 0000000000..967e69e539 --- /dev/null +++ b/internal/notification/repository/eventsourcing/view/custom_texts.go @@ -0,0 +1,61 @@ +package view + +import ( + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore/v1/models" + "github.com/caos/zitadel/internal/iam/repository/view" + "github.com/caos/zitadel/internal/iam/repository/view/model" + global_view "github.com/caos/zitadel/internal/view/repository" +) + +const ( + customTextTable = "notification.custom_texts" +) + +func (v *View) CustomTextByIDs(aggregateID, template, lang, key string) (*model.CustomTextView, error) { + return view.CustomTextByIDs(v.Db, customTextTable, aggregateID, template, lang, key) +} + +func (v *View) CustomTextsByAggregateIDAndTemplate(aggregateID, template string) ([]*model.CustomTextView, error) { + return view.GetCustomTextsByAggregateIDAndTemplate(v.Db, customTextTable, aggregateID, template) +} + +func (v *View) CustomTextsByAggregateIDAndTemplateAndLang(aggregateID, template, lang string) ([]*model.CustomTextView, error) { + return view.GetCustomTexts(v.Db, customTextTable, aggregateID, template, lang) +} + +func (v *View) PutCustomText(template *model.CustomTextView, event *models.Event) error { + err := view.PutCustomText(v.Db, customTextTable, template) + if err != nil { + return err + } + return v.ProcessedCustomTextSequence(event) +} + +func (v *View) DeleteCustomText(aggregateID, textType, lang string, event *models.Event) error { + err := view.DeleteCustomText(v.Db, customTextTable, aggregateID, textType, lang) + if err != nil && !errors.IsNotFound(err) { + return err + } + return v.ProcessedCustomTextSequence(event) +} + +func (v *View) GetLatestCustomTextSequence() (*global_view.CurrentSequence, error) { + return v.latestSequence(customTextTable) +} + +func (v *View) ProcessedCustomTextSequence(event *models.Event) error { + return v.saveCurrentSequence(customTextTable, event) +} + +func (v *View) UpdateCustomTextSpoolerRunTimestamp() error { + return v.updateSpoolerRunSequence(customTextTable) +} + +func (v *View) GetLatestCustomTextFailedEvent(sequence uint64) (*global_view.FailedEvent, error) { + return v.latestFailedEvent(customTextTable, sequence) +} + +func (v *View) ProcessedCustomTextFailedEvent(failedEvent *global_view.FailedEvent) error { + return v.saveFailedEvent(failedEvent) +} diff --git a/internal/notification/templates/templateData.go b/internal/notification/templates/templateData.go index 3197b52c3b..9e2b103ae8 100644 --- a/internal/notification/templates/templateData.go +++ b/internal/notification/templates/templateData.go @@ -5,6 +5,7 @@ import ( "html" "strings" + "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" ) @@ -16,10 +17,6 @@ const ( defaultFontColor = "#22292f" defaultBackgroundColor = "#fafafa" defaultPrimaryColor = "#5282C1" - //defaultOrgName = "CAOS AG" - //defaultOrgURL = "http://www.caos.ch" - //defaultFooter1 = "Teufener Strasse 19" - //defaultFooter2 = "CH-9000 St. Gallen" ) type TemplateData struct { @@ -41,28 +38,19 @@ type TemplateData struct { FooterText string } -func (data *TemplateData) Translate(i18n *i18n.Translator, args map[string]interface{}, langs ...string) { - data.Title = i18n.Localize(data.Title, nil, langs...) - data.PreHeader = i18n.Localize(data.PreHeader, nil, langs...) - data.Subject = i18n.Localize(data.Subject, nil, langs...) - data.Greeting = i18n.Localize(data.Greeting, args, langs...) - data.Text = html.UnescapeString(i18n.Localize(data.Text, args, langs...)) - if data.Href != "" { - data.Href = i18n.Localize(data.Href, nil, langs...) - } - data.ButtonText = i18n.Localize(data.ButtonText, nil, langs...) +func (data *TemplateData) Translate(translator *i18n.Translator, msgType string, args map[string]interface{}, langs ...string) { + data.Title = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageTitle), args, langs...) + data.PreHeader = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessagePreHeader), args, langs...) + data.Subject = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageSubject), args, langs...) + data.Greeting = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageGreeting), args, langs...) + data.Text = html.UnescapeString(translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageText), args, langs...)) + data.ButtonText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageButtonText), args, langs...) + data.FooterText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageFooterText), args, langs...) } -func GetTemplateData(apiDomain, href string, text *iam_model.MessageTextView, policy *iam_model.LabelPolicyView) TemplateData { +func GetTemplateData(translator *i18n.Translator, translateArgs map[string]interface{}, apiDomain, href, msgType, lang string, policy *iam_model.LabelPolicyView) TemplateData { templateData := TemplateData{ - Title: text.Title, - PreHeader: text.PreHeader, - Subject: text.Subject, - Greeting: text.Greeting, - Text: html.UnescapeString(text.Text), Href: href, - ButtonText: text.ButtonText, - FooterText: text.FooterText, PrimaryColor: defaultPrimaryColor, BackgroundColor: defaultBackgroundColor, FontColor: defaultFontColor, @@ -71,6 +59,7 @@ func GetTemplateData(apiDomain, href string, text *iam_model.MessageTextView, po FontFamily: defaultFontFamily, IncludeFooter: false, } + templateData.Translate(translator, msgType, translateArgs, lang) if policy.PrimaryColor != "" { templateData.PrimaryColor = policy.PrimaryColor } diff --git a/internal/notification/types/domain_claimed.go b/internal/notification/types/domain_claimed.go index 58461125a6..7e538c1af6 100644 --- a/internal/notification/types/domain_claimed.go +++ b/internal/notification/types/domain_claimed.go @@ -1,10 +1,11 @@ package types import ( - "html" "strings" "github.com/caos/zitadel/internal/config/systemdefaults" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" "github.com/caos/zitadel/internal/notification/templates" view_model "github.com/caos/zitadel/internal/user/repository/view/model" @@ -15,7 +16,7 @@ type DomainClaimedData struct { URL string } -func SendDomainClaimed(mailhtml string, text *iam_model.MessageTextView, user *view_model.NotifyUser, username string, systemDefaults systemdefaults.SystemDefaults, colors *iam_model.LabelPolicyView, apiDomain string) error { +func SendDomainClaimed(mailhtml string, translator *i18n.Translator, user *view_model.NotifyUser, username string, systemDefaults systemdefaults.SystemDefaults, colors *iam_model.LabelPolicyView, apiDomain string) error { url, err := templates.ParseTemplateText(systemDefaults.Notifications.Endpoints.DomainClaimed, &UrlData{UserID: user.ID}) if err != nil { return err @@ -24,17 +25,13 @@ func SendDomainClaimed(mailhtml string, text *iam_model.MessageTextView, user *v args["TempUsername"] = username args["Domain"] = strings.Split(user.LastEmail, "@")[1] - text.Greeting, err = templates.ParseTemplateText(text.Greeting, args) - text.Text, err = templates.ParseTemplateText(text.Text, args) - text.Text = html.UnescapeString(text.Text) - - emailCodeData := &DomainClaimedData{ - TemplateData: templates.GetTemplateData(apiDomain, url, text, colors), + domainClaimedData := &DomainClaimedData{ + TemplateData: templates.GetTemplateData(translator, args, apiDomain, url, domain.DomainClaimedMessageType, user.PreferredLanguage, colors), URL: url, } - template, err := templates.GetParsedTemplate(mailhtml, emailCodeData) + template, err := templates.GetParsedTemplate(mailhtml, domainClaimedData) if err != nil { return err } - return generateEmail(user, text.Subject, template, systemDefaults.Notifications, true) + return generateEmail(user, domainClaimedData.Subject, template, systemDefaults.Notifications, true) } diff --git a/internal/notification/types/email_verification_code.go b/internal/notification/types/email_verification_code.go index 4b88f17a91..bcc7b7c801 100644 --- a/internal/notification/types/email_verification_code.go +++ b/internal/notification/types/email_verification_code.go @@ -1,10 +1,10 @@ package types import ( - "html" - "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" "github.com/caos/zitadel/internal/notification/templates" es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" @@ -16,7 +16,7 @@ type EmailVerificationCodeData struct { URL string } -func SendEmailVerificationCode(mailhtml string, text *iam_model.MessageTextView, user *view_model.NotifyUser, code *es_model.EmailCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { +func SendEmailVerificationCode(mailhtml string, translator *i18n.Translator, user *view_model.NotifyUser, code *es_model.EmailCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { codeString, err := crypto.DecryptString(code.Code, alg) if err != nil { return err @@ -29,12 +29,8 @@ func SendEmailVerificationCode(mailhtml string, text *iam_model.MessageTextView, var args = mapNotifyUserToArgs(user) args["Code"] = codeString - text.Greeting, err = templates.ParseTemplateText(text.Greeting, args) - text.Text, err = templates.ParseTemplateText(text.Text, args) - text.Text = html.UnescapeString(text.Text) - emailCodeData := &EmailVerificationCodeData{ - TemplateData: templates.GetTemplateData(apiDomain, url, text, colors), + TemplateData: templates.GetTemplateData(translator, args, apiDomain, url, domain.VerifyEmailMessageType, user.PreferredLanguage, colors), URL: url, } @@ -42,5 +38,5 @@ func SendEmailVerificationCode(mailhtml string, text *iam_model.MessageTextView, if err != nil { return err } - return generateEmail(user, text.Subject, template, systemDefaults.Notifications, true) + return generateEmail(user, emailCodeData.Subject, template, systemDefaults.Notifications, true) } diff --git a/internal/notification/types/init_code.go b/internal/notification/types/init_code.go index 9fce037afe..48546b62ee 100644 --- a/internal/notification/types/init_code.go +++ b/internal/notification/types/init_code.go @@ -1,10 +1,10 @@ package types import ( - "html" - "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" "github.com/caos/zitadel/internal/notification/templates" es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" @@ -22,7 +22,7 @@ type UrlData struct { PasswordSet bool } -func SendUserInitCode(mailhtml string, text *iam_model.MessageTextView, user *view_model.NotifyUser, code *es_model.InitUserCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { +func SendUserInitCode(mailhtml string, translator *i18n.Translator, user *view_model.NotifyUser, code *es_model.InitUserCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { codeString, err := crypto.DecryptString(code.Code, alg) if err != nil { return err @@ -34,17 +34,13 @@ func SendUserInitCode(mailhtml string, text *iam_model.MessageTextView, user *vi var args = mapNotifyUserToArgs(user) args["Code"] = codeString - text.Greeting, err = templates.ParseTemplateText(text.Greeting, args) - text.Text, err = templates.ParseTemplateText(text.Text, args) - text.Text = html.UnescapeString(text.Text) - - emailCodeData := &InitCodeEmailData{ - TemplateData: templates.GetTemplateData(apiDomain, url, text, colors), + initCodeData := &InitCodeEmailData{ + TemplateData: templates.GetTemplateData(translator, args, apiDomain, url, domain.InitCodeMessageType, user.PreferredLanguage, colors), URL: url, } - template, err := templates.GetParsedTemplate(mailhtml, emailCodeData) + template, err := templates.GetParsedTemplate(mailhtml, initCodeData) if err != nil { return err } - return generateEmail(user, text.Subject, template, systemDefaults.Notifications, true) + return generateEmail(user, initCodeData.Subject, template, systemDefaults.Notifications, true) } diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go index 1eb1179362..0e8e4d3a00 100644 --- a/internal/notification/types/password_code.go +++ b/internal/notification/types/password_code.go @@ -1,10 +1,10 @@ package types import ( - "html" - "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/i18n" iam_model "github.com/caos/zitadel/internal/iam/model" "github.com/caos/zitadel/internal/notification/templates" es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" @@ -18,7 +18,7 @@ type PasswordCodeData struct { URL string } -func SendPasswordCode(mailhtml string, text *iam_model.MessageTextView, user *view_model.NotifyUser, code *es_model.PasswordCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { +func SendPasswordCode(mailhtml string, translator *i18n.Translator, user *view_model.NotifyUser, code *es_model.PasswordCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm, colors *iam_model.LabelPolicyView, apiDomain string) error { codeString, err := crypto.DecryptString(code.Code, alg) if err != nil { return err @@ -30,19 +30,15 @@ func SendPasswordCode(mailhtml string, text *iam_model.MessageTextView, user *vi var args = mapNotifyUserToArgs(user) args["Code"] = codeString - text.Greeting, err = templates.ParseTemplateText(text.Greeting, args) - text.Text, err = templates.ParseTemplateText(text.Text, args) - text.Text = html.UnescapeString(text.Text) - - emailCodeData := &PasswordCodeData{ - TemplateData: templates.GetTemplateData(apiDomain, url, text, colors), + passwordResetData := &PasswordCodeData{ + TemplateData: templates.GetTemplateData(translator, args, apiDomain, url, domain.PasswordResetMessageType, user.PreferredLanguage, colors), FirstName: user.FirstName, LastName: user.LastName, URL: url, } - template, err := templates.GetParsedTemplate(mailhtml, emailCodeData) + template, err := templates.GetParsedTemplate(mailhtml, passwordResetData) if err != nil { return err } - return generateEmail(user, text.Subject, template, systemDefaults.Notifications, true) + return generateEmail(user, passwordResetData.Subject, template, systemDefaults.Notifications, true) } diff --git a/internal/notification/types/phone_verification_code.go b/internal/notification/types/phone_verification_code.go index 28447ce2e4..55fe9ad3c2 100644 --- a/internal/notification/types/phone_verification_code.go +++ b/internal/notification/types/phone_verification_code.go @@ -1,9 +1,12 @@ package types import ( + "fmt" + "github.com/caos/zitadel/internal/config/systemdefaults" "github.com/caos/zitadel/internal/crypto" - iam_model "github.com/caos/zitadel/internal/iam/model" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/notification/templates" es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model" view_model "github.com/caos/zitadel/internal/user/repository/view/model" @@ -13,7 +16,7 @@ type PhoneVerificationCodeData struct { UserID string } -func SendPhoneVerificationCode(text *iam_model.MessageTextView, user *view_model.NotifyUser, code *es_model.PhoneCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error { +func SendPhoneVerificationCode(translator *i18n.Translator, user *view_model.NotifyUser, code *es_model.PhoneCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error { codeString, err := crypto.DecryptString(code.Code, alg) if err != nil { return err @@ -21,10 +24,10 @@ func SendPhoneVerificationCode(text *iam_model.MessageTextView, user *view_model var args = mapNotifyUserToArgs(user) args["Code"] = codeString - text.Text, err = templates.ParseTemplateText(text.Text, args) + text := translator.Localize(fmt.Sprintf("%s.%s", domain.VerifyPhoneMessageType, domain.MessageTitle), args, user.PreferredLanguage) codeData := &PhoneVerificationCodeData{UserID: user.ID} - template, err := templates.ParseTemplateText(text.Text, codeData) + template, err := templates.ParseTemplateText(text, codeData) if err != nil { return err } diff --git a/migrations/cockroach/V1.55__custom_text.sql b/migrations/cockroach/V1.55__custom_text.sql new file mode 100644 index 0000000000..99b078f972 --- /dev/null +++ b/migrations/cockroach/V1.55__custom_text.sql @@ -0,0 +1,14 @@ +CREATE TABLE notification.custom_texts ( + aggregate_id TEXT, + + creation_date TIMESTAMPTZ, + change_date TIMESTAMPTZ, + sequence BIGINT, + + template TEXT, + language TEXT, + key TEXT, + text TEXT, + + PRIMARY KEY (aggregate_id, template, key, language) +); diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index d689aa4fbc..908946a344 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -141,7 +141,18 @@ service AdminService { } }; } - + + // Returns the default languages + rpc GetSupportedLanguages(GetSupportedLanguagesRequest) returns (GetSupportedLanguagesResponse) { + option (google.api.http) = { + get: "/languages"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + } + //Checks whether an organisation exists by the given parameters rpc IsOrgUnique(IsOrgUniqueRequest) returns (IsOrgUniqueResponse) { option (google.api.http) = { @@ -1509,8 +1520,19 @@ service AdminService { }; } - //Returns the custom text for initial message + //Returns the default text for initial message (translation file) rpc GetDefaultInitMessageText(GetDefaultInitMessageTextRequest) returns (GetDefaultInitMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/init/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for initial message (overwritten in eventstore) + rpc GetCustomInitMessageText(GetCustomInitMessageTextRequest) returns (GetCustomInitMessageTextResponse) { option (google.api.http) = { get: "/text/message/init/{language}"; }; @@ -1535,8 +1557,19 @@ service AdminService { }; } - //Returns the custom text for password reset message + //Returns the default text for password reset message (translation file) rpc GetDefaultPasswordResetMessageText(GetDefaultPasswordResetMessageTextRequest) returns (GetDefaultPasswordResetMessageTextResponse) { + option (google.api.http) = { + get: "/text/deafult/message/passwordreset/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for password reset message (overwritten in eventstore) + rpc GetCustomPasswordResetMessageText(GetCustomPasswordResetMessageTextRequest) returns (GetCustomPasswordResetMessageTextResponse) { option (google.api.http) = { get: "/text/message/passwordreset/{language}"; }; @@ -1562,8 +1595,19 @@ service AdminService { } - //Returns the custom text for verify email message + //Returns the default text for verify email message (translation files) rpc GetDefaultVerifyEmailMessageText(GetDefaultVerifyEmailMessageTextRequest) returns (GetDefaultVerifyEmailMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/verifyemail/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for verify email message (overwritten in eventstore) + rpc GetCustomVerifyEmailMessageText(GetCustomVerifyEmailMessageTextRequest) returns (GetCustomVerifyEmailMessageTextResponse) { option (google.api.http) = { get: "/text/message/verifyemail/{language}"; }; @@ -1588,8 +1632,19 @@ service AdminService { }; } - //Returns the custom text for verify phone message + //Returns the default text for verify phone message (translation file) rpc GetDefaultVerifyPhoneMessageText(GetDefaultVerifyPhoneMessageTextRequest) returns (GetDefaultVerifyPhoneMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/verifyphone/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for verify phone message + rpc GetCustomVerifyPhoneMessageText(GetCustomVerifyPhoneMessageTextRequest) returns (GetCustomVerifyPhoneMessageTextResponse) { option (google.api.http) = { get: "/text/message/verifyphone/{language}"; }; @@ -1597,7 +1652,6 @@ service AdminService { option (zitadel.v1.auth_option) = { permission: "iam.policy.read"; }; - } //Sets the default custom text for verify phone message @@ -1615,8 +1669,19 @@ service AdminService { }; } - //Returns the custom text for domain claimed message + //Returns the default text for domain claimed message (translation file) rpc GetDefaultDomainClaimedMessageText(GetDefaultDomainClaimedMessageTextRequest) returns (GetDefaultDomainClaimedMessageTextResponse) { + option (google.api.http) = { + get: "/text/default/message/domainclaimed/{language}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.policy.read"; + }; + } + + //Returns the custom text for domain claimed message (overwritten in eventstore) + rpc GetCustomDomainClaimedMessageText(GetCustomDomainClaimedMessageTextRequest) returns (GetCustomDomainClaimedMessageTextResponse) { option (google.api.http) = { get: "/text/message/domainclaimed/{language}"; }; @@ -1980,6 +2045,13 @@ message HealthzRequest {} //This is an empty response message HealthzResponse {} +//This is an empty request +message GetSupportedLanguagesRequest {} + +message GetSupportedLanguagesResponse { + repeated string languages = 1; +} + // if name or domain is already in use, org is not unique message IsOrgUniqueRequest { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { @@ -3050,6 +3122,14 @@ message GetDefaultInitMessageTextResponse { zitadel.text.v1.MessageCustomText custom_text = 1; } +message GetCustomInitMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomInitMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + message SetDefaultInitMessageTextRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -3078,6 +3158,14 @@ message GetDefaultPasswordResetMessageTextResponse { zitadel.text.v1.MessageCustomText custom_text = 1; } +message GetCustomPasswordResetMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomPasswordResetMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + message SetDefaultPasswordResetMessageTextRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -3106,6 +3194,14 @@ message GetDefaultVerifyEmailMessageTextResponse { zitadel.text.v1.MessageCustomText custom_text = 1; } +message GetCustomVerifyEmailMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomVerifyEmailMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + message SetDefaultVerifyEmailMessageTextRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -3134,6 +3230,14 @@ message GetDefaultVerifyPhoneMessageTextResponse { zitadel.text.v1.MessageCustomText custom_text = 1; } +message GetCustomVerifyPhoneMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomVerifyPhoneMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + message SetDefaultVerifyPhoneMessageTextRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -3162,6 +3266,14 @@ message GetDefaultDomainClaimedMessageTextResponse { zitadel.text.v1.MessageCustomText custom_text = 1; } +message GetCustomDomainClaimedMessageTextRequest { + string language = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message GetCustomDomainClaimedMessageTextResponse { + zitadel.text.v1.MessageCustomText custom_text = 1; +} + message SetDefaultDomainClaimedMessageTextRequest { string language = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index a248d92935..b1d50daa2a 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -49,6 +49,17 @@ service AuthService { }; } + // Returns the default languages + rpc GetSupportedLanguages(GetSupportedLanguagesRequest) returns (GetSupportedLanguagesResponse) { + option (google.api.http) = { + get: "/languages"; + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated"; + }; + } + // Returns my full blown user rpc GetMyUser(GetMyUserRequest) returns (GetMyUserResponse) { option (google.api.http) = { @@ -504,6 +515,14 @@ message HealthzRequest {} //This is an empty response message HealthzResponse {} +//This is an empty request +message GetSupportedLanguagesRequest {} + +//This is an empty response +message GetSupportedLanguagesResponse { + repeated string languages = 1; +} + //This is an empty request // the request parameters are read from the token-header message GetMyUserRequest {} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index d21c82bd4e..4f409457c5 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -84,6 +84,17 @@ service ManagementService { }; } + // Returns the default languages + rpc GetSupportedLanguages(GetSupportedLanguagesRequest) returns (GetSupportedLanguagesResponse) { + option (google.api.http) = { + get: "/languages"; + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated"; + }; + } + // Returns the requested full blown user (human or machine) rpc GetUserByID(GetUserByIDRequest) returns (GetUserByIDResponse) { option (google.api.http) = { @@ -2568,6 +2579,13 @@ message GetIAMResponse { string iam_project_id = 2; } +//This is an empty request +message GetSupportedLanguagesRequest {} + +message GetSupportedLanguagesResponse { + repeated string languages = 1; +} + message GetUserByIDRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; }