From 28f7218ea1068a4bc7587166ce3a17af20ff4156 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 18 Jun 2025 13:24:39 +0200 Subject: [PATCH] feat: Hosted login translation API (#10011) # Which Problems Are Solved This PR implements https://github.com/zitadel/zitadel/issues/9850 # How the Problems Are Solved - New protobuf definition - Implementation of retrieval of system translations - Implementation of retrieval and persistence of organization and instance level translations # Additional Context - Closes #9850 # TODO - [x] Integration tests for Get and Set hosted login translation endpoints - [x] DB migration test - [x] Command function tests - [x] Command util functions tests - [x] Query function test - [x] Query util functions tests --- go.mod | 3 +- go.sum | 2 + internal/api/authz/context_mock.go | 24 +- .../v2/integration_test/query_test.go | 432 +++++ .../v2/integration_test/server_test.go | 9 +- .../v2/integration_test/settings_test.go | 371 +--- internal/api/grpc/settings/v2/query.go | 209 +++ internal/api/grpc/settings/v2/settings.go | 201 +-- internal/command/hosted_login_translation.go | 73 + .../command/hosted_login_translation_model.go | 45 + .../command/hosted_login_translation_test.go | 211 +++ internal/database/mock/sql_mock.go | 12 +- internal/query/hosted_login_translation.go | 256 +++ .../query/hosted_login_translation_test.go | 337 ++++ .../projection/hosted_login_translation.go | 144 ++ internal/query/projection/projection.go | 3 + internal/query/v2-default.json | 1557 +++++++++++++++++ internal/repository/instance/eventstore.go | 1 + .../instance/hosted_login_translation.go | 55 + internal/repository/org/eventstore.go | 1 + .../org/hosted_login_translation.go | 55 + proto/zitadel/settings/v2/settings.proto | 2 +- .../settings/v2/settings_service.proto | 137 ++ 23 files changed, 3613 insertions(+), 527 deletions(-) create mode 100644 internal/api/grpc/settings/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/settings/v2/query.go create mode 100644 internal/command/hosted_login_translation.go create mode 100644 internal/command/hosted_login_translation_model.go create mode 100644 internal/command/hosted_login_translation_test.go create mode 100644 internal/query/hosted_login_translation.go create mode 100644 internal/query/hosted_login_translation_test.go create mode 100644 internal/query/projection/hosted_login_translation.go create mode 100644 internal/query/v2-default.json create mode 100644 internal/repository/instance/hosted_login_translation.go create mode 100644 internal/repository/org/hosted_login_translation.go diff --git a/go.mod b/go.mod index 15b3d2b391..9d02050b48 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 @@ -65,6 +66,7 @@ require ( github.com/riverqueue/river v0.22.0 github.com/riverqueue/river/riverdriver v0.22.0 github.com/riverqueue/river/rivertype v0.22.0 + github.com/riverqueue/rivercontrib/otelriver v0.5.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/shopspring/decimal v1.3.1 @@ -146,7 +148,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/riverqueue/river/rivershared v0.22.0 // indirect - github.com/riverqueue/rivercontrib/otelriver v0.5.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 272ce655a3..e2ab9768a6 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go index 6891030bd3..d26b371bc6 100644 --- a/internal/api/authz/context_mock.go +++ b/internal/api/authz/context_mock.go @@ -1,10 +1,28 @@ package authz -import "context" +import ( + "context" -func NewMockContext(instanceID, orgID, userID string) context.Context { + "golang.org/x/text/language" +) + +type MockContextInstanceOpts func(i *instance) + +func WithMockDefaultLanguage(lang language.Tag) MockContextInstanceOpts { + return func(i *instance) { + i.defaultLanguage = lang + } +} + +func NewMockContext(instanceID, orgID, userID string, opts ...MockContextInstanceOpts) context.Context { ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) - return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) + + i := &instance{id: instanceID} + for _, o := range opts { + o(i) + } + + return context.WithValue(ctx, instanceKey, i) } func NewMockContextWithAgent(instanceID, orgID, userID, agentID string) context.Context { diff --git a/internal/api/grpc/settings/v2/integration_test/query_test.go b/internal/api/grpc/settings/v2/integration_test/query_test.go new file mode 100644 index 0000000000..c3bf54e992 --- /dev/null +++ b/internal/api/grpc/settings/v2/integration_test/query_test.go @@ -0,0 +1,432 @@ +//go:build integration + +package settings_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/idp" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestServer_GetSecuritySettings(t *testing.T) { + _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *settings.GetSecuritySettingsResponse + wantErr bool + }{ + { + name: "permission error", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + wantErr: true, + }, + { + name: "success", + ctx: AdminCTX, + want: &settings.GetSecuritySettingsResponse{ + Settings: &settings.SecuritySettings{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + got, want := resp.GetSettings(), tt.want.GetSettings() + assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") + assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") + assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") + }, retryDuration, tick) + }) + } +} + +func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { + return &settings.IdentityProvider{ + Id: id, + Name: name, + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, + Options: &idp_pb.Options{ + IsLinkingAllowed: linking, + IsCreationAllowed: creation, + IsAutoCreation: autoCreation, + IsAutoUpdate: autoUpdate, + AutoLinking: autoLinking, + }, + } +} + +func TestServer_GetActiveIdentityProviders(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive + idpActiveName := gofakeit.AppName() + idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) + idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpLinkingDisallowedName := gofakeit.AppName() + idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) + idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpCreationDisallowedName := gofakeit.AppName() + idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) + idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoCreationName := gofakeit.AppName() + idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) + idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoLinkingName := gofakeit.AppName() + idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) + idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + + type args struct { + ctx context.Context + req *settings.GetActiveIdentityProvidersRequest + } + tests := []struct { + name string + args args + want *settings.GetActiveIdentityProvidersResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + wantErr: true, + }, + { + name: "success, all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 5, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpLinkingDisallowedResponse, + }, + }, + }, + { + name: "success, exclude creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpCreationDisallowedResponse, + }, + }, + }, + { + name: "success, auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, no auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, no auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + CreationAllowed: gu.Ptr(true), + AutoCreation: gu.Ptr(true), + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + for i, result := range tt.want.GetIdentityProviders() { + assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) + } + integration.AssertListDetails(ct, tt.want, got) + }, retryDuration, tick) + }) + } +} + +func TestServer_GetHostedLoginTranslation(t *testing.T) { + // Given + translations := map[string]any{"loginTitle": gofakeit.Slogan()} + + protoTranslations, err := structpb.NewStruct(translations) + require.NoError(t, err) + + setupRequest := &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Translations: protoTranslations, + Locale: gofakeit.LanguageBCP(), + } + savedTranslation, err := Client.SetHostedLoginTranslation(AdminCTX, setupRequest) + require.NoError(t, err) + + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputCtx: OrgOwnerCtx, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Locale: setupRequest.GetLocale(), + }, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Etag: savedTranslation.GetEtag(), + Translations: protoTranslations, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.GetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.NotEmpty(t, res.GetEtag()) + assert.NotEmpty(t, res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/api/grpc/settings/v2/integration_test/server_test.go b/internal/api/grpc/settings/v2/integration_test/server_test.go index d57e2a7694..c5c851c310 100644 --- a/internal/api/grpc/settings/v2/integration_test/server_test.go +++ b/internal/api/grpc/settings/v2/integration_test/server_test.go @@ -13,9 +13,9 @@ import ( ) var ( - CTX, AdminCTX context.Context - Instance *integration.Instance - Client settings.SettingsServiceClient + CTX, AdminCTX, UserTypeLoginCtx, OrgOwnerCtx context.Context + Instance *integration.Instance + Client settings.SettingsServiceClient ) func TestMain(m *testing.M) { @@ -27,6 +27,9 @@ func TestMain(m *testing.M) { CTX = ctx AdminCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + UserTypeLoginCtx = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + Client = Instance.Client.SettingsV2 return m.Run() }()) diff --git a/internal/api/grpc/settings/v2/integration_test/settings_test.go b/internal/api/grpc/settings/v2/integration_test/settings_test.go index 3430eae5f8..7d1e4b0239 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -4,78 +4,23 @@ package settings_test import ( "context" + "crypto/md5" + "encoding/hex" + "fmt" "testing" - "time" - "github.com/brianvoe/gofakeit/v6" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/idp" - idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func TestServer_GetSecuritySettings(t *testing.T) { - _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{ - EmbeddedIframe: &settings.EmbeddedIframeSettings{ - Enabled: true, - AllowedOrigins: []string{"foo", "bar"}, - }, - EnableImpersonation: true, - }) - require.NoError(t, err) - - tests := []struct { - name string - ctx context.Context - want *settings.GetSecuritySettingsResponse - wantErr bool - }{ - { - name: "permission error", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), - wantErr: true, - }, - { - name: "success", - ctx: AdminCTX, - want: &settings.GetSecuritySettingsResponse{ - Settings: &settings.SecuritySettings{ - EmbeddedIframe: &settings.EmbeddedIframeSettings{ - Enabled: true, - AllowedOrigins: []string{"foo", "bar"}, - }, - EnableImpersonation: true, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - got, want := resp.GetSettings(), tt.want.GetSettings() - assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") - assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") - assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") - }, retryDuration, tick) - }) - } -} - func TestServer_SetSecuritySettings(t *testing.T) { type args struct { ctx context.Context @@ -183,280 +128,64 @@ func TestServer_SetSecuritySettings(t *testing.T) { } } -func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { - return &settings.IdentityProvider{ - Id: id, - Name: name, - Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, - Options: &idp_pb.Options{ - IsLinkingAllowed: linking, - IsCreationAllowed: creation, - IsAutoCreation: autoCreation, - IsAutoUpdate: autoUpdate, - AutoLinking: autoLinking, - }, - } -} +func TestSetHostedLoginTranslation(t *testing.T) { + translations := map[string]any{"loginTitle": "Welcome to our service"} -func TestServer_GetActiveIdentityProviders(t *testing.T) { - instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + protoTranslations, err := structpb.NewStruct(translations) + require.Nil(t, err) - instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive - idpActiveName := gofakeit.AppName() - idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) - idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpLinkingDisallowedName := gofakeit.AppName() - idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) - idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpCreationDisallowedName := gofakeit.AppName() - idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) - idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoCreationName := gofakeit.AppName() - idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) - idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoLinkingName := gofakeit.AppName() - idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) - idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + hash := md5.Sum(fmt.Append(nil, translations)) - type args struct { - ctx context.Context - req *settings.GetActiveIdentityProvidersRequest - } - tests := []struct { - name string - args args - want *settings.GetActiveIdentityProvidersResponse - wantErr bool + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.SetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.SetHostedLoginTranslationResponse }{ { - name: "permission error", - args: args{ - ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - wantErr: true, + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", }, { - name: "success, all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 5, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, + testName: "when unauthZ context should return unauthZ error", + inputCtx: UserTypeLoginCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", }, { - name: "success, exclude linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), }, + Translations: protoTranslations, + Locale: "en-US", }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpLinkingDisallowedResponse, - }, - }, - }, - { - name: "success, exclude creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpCreationDisallowedResponse, - }, - }, - }, - { - name: "success, auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, no auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, no auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, exclude all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), - CreationAllowed: gu.Ptr(true), - AutoCreation: gu.Ptr(true), - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - }, + expectedResponse: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hash[:]), }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - for i, result := range tt.want.GetIdentityProviders() { - assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) - } - integration.AssertListDetails(ct, tt.want, got) - }, retryDuration, tick) + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.SetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse.GetEtag(), res.GetEtag()) + } }) } } diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go new file mode 100644 index 0000000000..b8994ccb87 --- /dev/null +++ b/internal/api/grpc/settings/v2/query.go @@ -0,0 +1,209 @@ +package settings + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/query" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLoginSettingsResponse{ + Settings: loginSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.OrgID, + }, + }, nil +} + +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordComplexitySettingsResponse{ + Settings: passwordComplexitySettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordExpirySettingsResponse{ + Settings: passwordExpirySettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetBrandingSettingsResponse{ + Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetDomainSettingsResponse{ + Settings: domainSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLegalAndSupportSettingsResponse{ + Settings: legalAndSupportSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) + if err != nil { + return nil, err + } + return &settings.GetLockoutSettingsResponse{ + Settings: lockoutSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { + queries, err := activeIdentityProvidersToQuery(req) + if err != nil { + return nil, err + } + + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + if err != nil { + return nil, err + } + + return &settings.GetActiveIdentityProvidersResponse{ + Details: object.ToListDetails(links.SearchResponse), + IdentityProviders: identityProvidersToPb(links.Links), + }, nil +} + +func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, 0, 4) + if req.CreationAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.LinkingAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoCreation != nil { + creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoLinking != nil { + compare := query.NumberEquals + if *req.AutoLinking { + compare = query.NumberNotEquals + } + creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + return q, nil +} + +func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { + instance := authz.GetInstance(ctx) + return &settings.GetGeneralSettingsResponse{ + SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), + DefaultOrgId: instance.DefaultOrganisationID(), + DefaultLanguage: instance.DefaultLanguage().String(), + }, nil +} + +func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { + policy, err := s.query.SecurityPolicy(ctx) + if err != nil { + return nil, err + } + return &settings.GetSecuritySettingsResponse{ + Settings: securityPolicyToSettingsPb(policy), + }, nil +} + +func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return translation, nil +} diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 77874bf970..09ee6b27c8 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,202 +3,10 @@ package settings import ( "context" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/i18n" - "github.com/zitadel/zitadel/internal/query" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetLoginSettingsResponse{ - Settings: loginSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.OrgID, - }, - }, nil -} - -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetPasswordComplexitySettingsResponse{ - Settings: passwordComplexitySettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetPasswordExpirySettingsResponse{ - Settings: passwordExpirySettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetBrandingSettingsResponse{ - Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetDomainSettingsResponse{ - Settings: domainSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetLegalAndSupportSettingsResponse{ - Settings: legalAndSupportSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) - if err != nil { - return nil, err - } - return &settings.GetLockoutSettingsResponse{ - Settings: lockoutSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) - if err != nil { - return nil, err - } - - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) - if err != nil { - return nil, err - } - - return &settings.GetActiveIdentityProvidersResponse{ - Details: object.ToListDetails(links.SearchResponse), - IdentityProviders: identityProvidersToPb(links.Links), - }, nil -} - -func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { - q := make([]query.SearchQuery, 0, 4) - if req.CreationAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.LinkingAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoCreation != nil { - creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoLinking != nil { - compare := query.NumberEquals - if *req.AutoLinking { - compare = query.NumberNotEquals - } - creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - return q, nil -} - -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { - instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ - SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), - DefaultOrgId: instance.DefaultOrganisationID(), - DefaultLanguage: instance.DefaultLanguage().String(), - }, nil -} - -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { - policy, err := s.query.SecurityPolicy(ctx) - if err != nil { - return nil, err - } - return &settings.GetSecuritySettingsResponse{ - Settings: securityPolicyToSettingsPb(policy), - }, nil -} - func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) if err != nil { @@ -208,3 +16,12 @@ func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecur Details: object.DomainToDetailsPb(details), }, nil } + +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/internal/command/hosted_login_translation.go b/internal/command/hosted_login_translation.go new file mode 100644 index 0000000000..024ab6bdad --- /dev/null +++ b/internal/command/hosted_login_translation.go @@ -0,0 +1,73 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func (c *Commands) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (res *settings.SetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var agg eventstore.Aggregate + switch t := req.GetLevel().(type) { + case *settings.SetHostedLoginTranslationRequest_Instance: + agg = instance.NewAggregate(authz.GetInstance(ctx).InstanceID()).Aggregate + case *settings.SetHostedLoginTranslationRequest_OrganizationId: + agg = org.NewAggregate(t.OrganizationId).Aggregate + default: + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + lang, err := language.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid") + } + + commands, wm, err := c.setTranslationEvents(ctx, agg, lang, req.GetTranslations().AsMap()) + if err != nil { + return nil, err + } + + pushedEvents, err := c.eventstore.Push(ctx, commands...) + if err != nil { + return nil, zerrors.ThrowInternal(err, "COMMA-i8nqFl", "Errors.Internal") + } + + err = AppendAndReduce(wm, pushedEvents...) + if err != nil { + return nil, err + } + + etag := md5.Sum(fmt.Append(nil, wm.Translation)) + return &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(etag[:]), + }, nil +} + +func (c *Commands) setTranslationEvents(ctx context.Context, agg eventstore.Aggregate, lang language.Tag, translations map[string]any) ([]eventstore.Command, *HostedLoginTranslationWriteModel, error) { + wm := NewHostedLoginTranslationWriteModel(agg.ID) + events := []eventstore.Command{} + switch agg.Type { + case instance.AggregateType: + events = append(events, instance.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + case org.AggregateType: + events = append(events, org.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + default: + return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid") + } + + return events, wm, nil +} diff --git a/internal/command/hosted_login_translation_model.go b/internal/command/hosted_login_translation_model.go new file mode 100644 index 0000000000..16bc42c541 --- /dev/null +++ b/internal/command/hosted_login_translation_model.go @@ -0,0 +1,45 @@ +package command + +import ( + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" +) + +type HostedLoginTranslationWriteModel struct { + eventstore.WriteModel + Language language.Tag + Translation map[string]any + Level string + LevelID string +} + +func NewHostedLoginTranslationWriteModel(resourceID string) *HostedLoginTranslationWriteModel { + return &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: resourceID, + ResourceOwner: resourceID, + }, + } +} + +func (wm *HostedLoginTranslationWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *org.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + case *instance.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + } + } + + return wm.WriteModel.Reduce() +} diff --git a/internal/command/hosted_login_translation_test.go b/internal/command/hosted_login_translation_test.go new file mode 100644 index 0000000000..a5f0941711 --- /dev/null +++ b/internal/command/hosted_login_translation_test.go @@ -0,0 +1,211 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/service" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestSetTranslationEvents(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + + tt := []struct { + testName string + + inputAggregate eventstore.Aggregate + inputLanguage language.Tag + inputTranslations map[string]any + + expectedCommands []eventstore.Command + expectedWriteModel *HostedLoginTranslationWriteModel + expectedError error + }{ + { + testName: "when aggregate type is instance should return matching write model and instance.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + instance.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-US")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is org should return matching write model and org.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: org.AggregateType}, + inputLanguage: language.MustParse("en-GB"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + org.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: org.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-GB")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is neither org nor instance should return invalid argument error", + inputAggregate: eventstore.Aggregate{ID: "123"}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{} + + // When + events, writeModel, err := c.setTranslationEvents(testCtx, tc.inputAggregate, tc.inputLanguage, tc.inputTranslations) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedWriteModel, writeModel) + + require.Len(t, events, len(tc.expectedCommands)) + assert.ElementsMatch(t, tc.expectedCommands, events) + }) + } +} + +func TestSetHostedLoginTranslation(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + testCtx = authz.WithInstanceID(testCtx, "instance-id") + + testTranslation := map[string]any{"test": "translation", "translation": "2"} + protoTranslation, err := structpb.NewStruct(testTranslation) + require.NoError(t, err) + + hashTestTranslation := md5.Sum(fmt.Append(nil, testTranslation)) + require.NotEmpty(t, hashTestTranslation) + + tt := []struct { + testName string + + mockPush func(*testing.T) *eventstore.Eventstore + + inputReq *settings.SetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.SetHostedLoginTranslationResponse + }{ + { + testName: "when locale is malformed should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "123", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when locale is unknown should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when event pushing fails should return internal error", + + mockPush: expectEventstore(expectPushFailed( + errors.New("mock push failed"), + instance.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "instance-id", + Type: instance.AggregateType, + ResourceOwner: "instance-id", + InstanceID: "instance-id", + Version: instance.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedError: zerrors.ThrowInternal(errors.New("mock push failed"), "COMMA-i8nqFl", "Errors.Internal"), + }, + { + testName: "when request is valid should return expected response", + + mockPush: expectEventstore(expectPush( + org.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "org-id", + Type: org.AggregateType, + ResourceOwner: "org-id", + InstanceID: "", + Version: org.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{OrganizationId: "org-id"}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedResult: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hashTestTranslation[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{ + eventstore: tc.mockPush(t), + } + + // When + res, err := c.SetHostedLoginTranslation(testCtx, tc.inputReq) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/database/mock/sql_mock.go b/internal/database/mock/sql_mock.go index b8030b269f..cd30cd9cf0 100644 --- a/internal/database/mock/sql_mock.go +++ b/internal/database/mock/sql_mock.go @@ -14,9 +14,9 @@ type SQLMock struct { mock sqlmock.Sqlmock } -type expectation func(m sqlmock.Sqlmock) +type Expectation func(m sqlmock.Sqlmock) -func NewSQLMock(t *testing.T, expectations ...expectation) *SQLMock { +func NewSQLMock(t *testing.T, expectations ...Expectation) *SQLMock { db, mock, err := sqlmock.New( sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual), sqlmock.ValueConverterOption(new(TypeConverter)), @@ -45,7 +45,7 @@ func (m *SQLMock) Assert(t *testing.T) { m.DB.Close() } -func ExpectBegin(err error) expectation { +func ExpectBegin(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectBegin() if err != nil { @@ -54,7 +54,7 @@ func ExpectBegin(err error) expectation { } } -func ExpectCommit(err error) expectation { +func ExpectCommit(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectCommit() if err != nil { @@ -89,7 +89,7 @@ func WithExecRowsAffected(affected driver.RowsAffected) ExecOpt { } } -func ExcpectExec(stmt string, opts ...ExecOpt) expectation { +func ExcpectExec(stmt string, opts ...ExecOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectExec(stmt) for _, opt := range opts { @@ -122,7 +122,7 @@ func WithQueryResult(columns []string, rows [][]driver.Value) QueryOpt { } } -func ExpectQuery(stmt string, opts ...QueryOpt) expectation { +func ExpectQuery(stmt string, opts ...QueryOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectQuery(stmt) for _, opt := range opts { diff --git a/internal/query/hosted_login_translation.go b/internal/query/hosted_login_translation.go new file mode 100644 index 0000000000..82193d2069 --- /dev/null +++ b/internal/query/hosted_login_translation.go @@ -0,0 +1,256 @@ +package query + +import ( + "context" + "crypto/md5" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "dario.cat/mergo" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/v2/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +var ( + //go:embed v2-default.json + defaultLoginTranslations []byte + + defaultSystemTranslations map[language.Tag]map[string]any + + hostedLoginTranslationTable = table{ + name: projection.HostedLoginTranslationTable, + instanceIDCol: projection.HostedLoginTranslationInstanceIDCol, + } + + hostedLoginTranslationColInstanceID = Column{ + name: projection.HostedLoginTranslationInstanceIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwner = Column{ + name: projection.HostedLoginTranslationAggregateIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwnerType = Column{ + name: projection.HostedLoginTranslationAggregateTypeCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColLocale = Column{ + name: projection.HostedLoginTranslationLocaleCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColFile = Column{ + name: projection.HostedLoginTranslationFileCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColEtag = Column{ + name: projection.HostedLoginTranslationEtagCol, + table: hostedLoginTranslationTable, + } +) + +func init() { + err := json.Unmarshal(defaultLoginTranslations, &defaultSystemTranslations) + if err != nil { + panic(err) + } +} + +type HostedLoginTranslations struct { + SearchResponse + HostedLoginTranslations []*HostedLoginTranslation +} + +type HostedLoginTranslation struct { + AggregateID string + Sequence uint64 + CreationDate time.Time + ChangeDate time.Time + + Locale string + File map[string]any + LevelType string + LevelID string + Etag string +} + +func (q *Queries) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (res *settings.GetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + inst := authz.GetInstance(ctx) + defaultInstLang := inst.DefaultLanguage() + + lang, err := language.BCP47.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid") + } + parentLang := lang.Parent() + if parentLang.IsRoot() { + parentLang = lang + } + + sysTranslation, systemEtag, err := getSystemTranslation(parentLang, defaultInstLang) + if err != nil { + return nil, err + } + + var levelID, resourceOwner string + switch t := req.GetLevel().(type) { + case *settings.GetHostedLoginTranslationRequest_System: + return getTranslationOutputMessage(sysTranslation, systemEtag) + case *settings.GetHostedLoginTranslationRequest_Instance: + levelID = authz.GetInstance(ctx).InstanceID() + resourceOwner = instance.AggregateType + case *settings.GetHostedLoginTranslationRequest_OrganizationId: + levelID = t.OrganizationId + resourceOwner = org.AggregateType + default: + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + stmt, scan := prepareHostedLoginTranslationQuery() + + langORBaseLang := sq.Or{ + sq.Eq{hostedLoginTranslationColLocale.identifier(): lang.String()}, + sq.Eq{hostedLoginTranslationColLocale.identifier(): parentLang.String()}, + } + eq := sq.Eq{ + hostedLoginTranslationColInstanceID.identifier(): inst.InstanceID(), + hostedLoginTranslationColResourceOwner.identifier(): levelID, + hostedLoginTranslationColResourceOwnerType.identifier(): resourceOwner, + } + + query, args, err := stmt.Where(eq).Where(langORBaseLang).ToSql() + if err != nil { + logging.WithError(err).Error("unable to generate sql statement") + return nil, zerrors.ThrowInternal(err, "QUERY-ZgCMux", "Errors.Query.SQLStatement") + } + + var trs []*HostedLoginTranslation + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + trs, err = scan(rows) + return err + }, query, args...) + if err != nil { + logging.WithError(err).Error("failed to query translations") + return nil, zerrors.ThrowInternal(err, "QUERY-6k1zjx", "Errors.Internal") + } + + requestedTranslation, parentTranslation := &HostedLoginTranslation{}, &HostedLoginTranslation{} + for _, tr := range trs { + if tr == nil { + continue + } + + if tr.LevelType == resourceOwner { + requestedTranslation = tr + } else { + parentTranslation = tr + } + } + + if !req.GetIgnoreInheritance() { + + // There is no record for the requested level, set the upper level etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = parentTranslation.Etag + } + + // Case where Level == ORGANIZATION -> Check if we have an instance level translation + // If so, merge it with the translations we have + if parentTranslation != nil && parentTranslation.LevelType == instance.AggregateType { + if err := mergo.Merge(&requestedTranslation.File, parentTranslation.File); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-pdgEJd", "Errors.Query.MergeTranslations") + } + } + + // The DB query returned no results, we have to set the system translation etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = systemEtag + } + + // Merge the system translations + if err := mergo.Merge(&requestedTranslation.File, sysTranslation); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-HdprNF", "Errors.Query.MergeTranslations") + } + } + + return getTranslationOutputMessage(requestedTranslation.File, requestedTranslation.Etag) +} + +func getSystemTranslation(lang, instanceDefaultLang language.Tag) (map[string]any, string, error) { + translation, ok := defaultSystemTranslations[lang] + if !ok { + translation, ok = defaultSystemTranslations[instanceDefaultLang] + if !ok { + return nil, "", zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", lang) + } + } + + hash := md5.Sum(fmt.Append(nil, translation)) + + return translation, hex.EncodeToString(hash[:]), nil +} + +func prepareHostedLoginTranslationQuery() (sq.SelectBuilder, func(*sql.Rows) ([]*HostedLoginTranslation, error)) { + return sq.Select( + hostedLoginTranslationColFile.identifier(), + hostedLoginTranslationColResourceOwnerType.identifier(), + hostedLoginTranslationColEtag.identifier(), + ).From(hostedLoginTranslationTable.identifier()). + Limit(2). + PlaceholderFormat(sq.Dollar), + func(r *sql.Rows) ([]*HostedLoginTranslation, error) { + translations := make([]*HostedLoginTranslation, 0, 2) + for r.Next() { + var rawTranslation json.RawMessage + translation := &HostedLoginTranslation{} + err := r.Scan( + &rawTranslation, + &translation.LevelType, + &translation.Etag, + ) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(rawTranslation, &translation.File); err != nil { + return nil, err + } + + translations = append(translations, translation) + } + + if err := r.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-oc7r7i", "Errors.Query.CloseRows") + } + + return translations, nil + } +} + +func getTranslationOutputMessage(translation map[string]any, etag string) (*settings.GetHostedLoginTranslationResponse, error) { + protoTranslation, err := structpb.NewStruct(translation) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct") + } + + return &settings.GetHostedLoginTranslationResponse{ + Translations: protoTranslation, + Etag: etag, + }, nil +} diff --git a/internal/query/hosted_login_translation_test.go b/internal/query/hosted_login_translation_test.go new file mode 100644 index 0000000000..0e9f511002 --- /dev/null +++ b/internal/query/hosted_login_translation_test.go @@ -0,0 +1,337 @@ +package query + +import ( + "crypto/md5" + "database/sql" + "database/sql/driver" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/database/mock" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestGetSystemTranslation(t *testing.T) { + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.Nil(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + hashOK := md5.Sum(fmt.Append(nil, parsedOKTranslation["de"])) + + tt := []struct { + testName string + + inputLanguage language.Tag + inputInstanceLanguage language.Tag + systemTranslationToSet []byte + + expectedLanguage map[string]any + expectedEtag string + expectedError error + }{ + { + testName: "when neither input language nor system default language have translation should return not found error", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("fr"), + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when input language has no translation should fallback onto instance default", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("de"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + { + testName: "when input language has translation should return it", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("de"), + inputInstanceLanguage: language.MustParse("en"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + defaultLoginTranslations = tc.systemTranslationToSet + + // When + translation, etag, err := getSystemTranslation(tc.inputLanguage, tc.inputInstanceLanguage) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedLanguage, translation) + assert.Equal(t, tc.expectedEtag, etag) + }) + } +} + +func TestGetTranslationOutput(t *testing.T) { + t.Parallel() + + validMap := map[string]any{"loginHeader": "A login header"} + protoMap, err := structpb.NewStruct(validMap) + require.NoError(t, err) + + hash := md5.Sum(fmt.Append(nil, validMap)) + encodedHash := hex.EncodeToString(hash[:]) + + tt := []struct { + testName string + inputTranslation map[string]any + expectedError error + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unparsable map should return internal error", + inputTranslation: map[string]any{"\xc5z": "something"}, + expectedError: zerrors.ThrowInternal(protoimpl.X.NewError("invalid UTF-8 in string: %q", "\xc5z"), "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct"), + }, + { + testName: "when input translation is valid should return expected response message", + inputTranslation: validMap, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Translations: protoMap, + Etag: hex.EncodeToString(hash[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := getTranslationOutputMessage(tc.inputTranslation, encodedHash) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestGetHostedLoginTranslation(t *testing.T) { + query := `SELECT projections.hosted_login_translations.file, projections.hosted_login_translations.aggregate_type, projections.hosted_login_translations.etag + FROM projections.hosted_login_translations + WHERE projections.hosted_login_translations.aggregate_id = $1 + AND projections.hosted_login_translations.aggregate_type = $2 + AND projections.hosted_login_translations.instance_id = $3 + AND (projections.hosted_login_translations.locale = $4 OR projections.hosted_login_translations.locale = $5) + LIMIT 2` + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.NoError(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + protoDefaultTranslation, err := structpb.NewStruct(parsedOKTranslation["en"]) + require.Nil(t, err) + + defaultWithDBTranslations := maps.Clone(parsedOKTranslation["en"]) + defaultWithDBTranslations["test"] = "translation" + defaultWithDBTranslations["test2"] = "translation2" + protoDefaultWithDBTranslation, err := structpb.NewStruct(defaultWithDBTranslations) + require.NoError(t, err) + + nilProtoDefaultMap, err := structpb.NewStruct(nil) + require.NoError(t, err) + + hashDefaultTranslations := md5.Sum(fmt.Append(nil, parsedOKTranslation["en"])) + + tt := []struct { + testName string + + defaultInstanceLanguage language.Tag + sqlExpectations []mock.Expectation + + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when input language is invalid should return invalid argument error", + + inputRequest: &settings.GetHostedLoginTranslationRequest{}, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when input language is root should return invalid argument error", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when no system translation is available should return not found error", + + defaultInstanceLanguage: language.Romanian, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "ro-RO", + }, + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when requesting system translation should return it", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_System{}, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB fails should return internal error", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryErr(sql.ErrConnDone), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedError: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-6k1zjx", "Errors.Internal"), + }, + { + testName: "when querying DB returns no result should return system translations", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB returns no result and inheritance disabled should return empty result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + IgnoreInheritance: true, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "", + Translations: nilProtoDefaultMap, + }, + }, + { + testName: "when querying DB returns records should return merged result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{ + {[]byte(`{"test": "translation"}`), "org", "etag-org"}, + {[]byte(`{"test2": "translation2"}`), "instance", "etag-instance"}, + }, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "etag-org", + Translations: protoDefaultWithDBTranslation, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + db := &database.DB{DB: mock.NewSQLMock(t, tc.sqlExpectations...).DB} + querier := Queries{client: db} + + ctx := authz.NewMockContext("instance-id", "org-id", "user-id", authz.WithMockDefaultLanguage(tc.defaultInstanceLanguage)) + + // When + res, err := querier.GetHostedLoginTranslation(ctx, tc.inputRequest) + + // Verify + require.Equal(t, tc.expectedError, err) + + if tc.expectedError == nil { + assert.Equal(t, tc.expectedResult.GetEtag(), res.GetEtag()) + assert.Equal(t, tc.expectedResult.GetTranslations().GetFields(), res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/query/projection/hosted_login_translation.go b/internal/query/projection/hosted_login_translation.go new file mode 100644 index 0000000000..865d3738b9 --- /dev/null +++ b/internal/query/projection/hosted_login_translation.go @@ -0,0 +1,144 @@ +package projection + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationTable = "projections.hosted_login_translations" + + HostedLoginTranslationInstanceIDCol = "instance_id" + HostedLoginTranslationCreationDateCol = "creation_date" + HostedLoginTranslationChangeDateCol = "change_date" + HostedLoginTranslationAggregateIDCol = "aggregate_id" + HostedLoginTranslationAggregateTypeCol = "aggregate_type" + HostedLoginTranslationSequenceCol = "sequence" + HostedLoginTranslationLocaleCol = "locale" + HostedLoginTranslationFileCol = "file" + HostedLoginTranslationEtagCol = "etag" +) + +type hostedLoginTranslationProjection struct{} + +func newHostedLoginTranslationProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(hostedLoginTranslationProjection)) +} + +// Init implements [handler.initializer] +func (p *hostedLoginTranslationProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(HostedLoginTranslationInstanceIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationAggregateIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationAggregateTypeCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(HostedLoginTranslationLocaleCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationFileCol, handler.ColumnTypeJSONB), + handler.NewColumn(HostedLoginTranslationEtagCol, handler.ColumnTypeText), + }, + handler.NewPrimaryKey( + HostedLoginTranslationInstanceIDCol, + HostedLoginTranslationAggregateIDCol, + HostedLoginTranslationAggregateTypeCol, + HostedLoginTranslationLocaleCol, + ), + ), + ) +} + +func (hltp *hostedLoginTranslationProjection) Name() string { + return HostedLoginTranslationTable +} + +func (hltp *hostedLoginTranslationProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + } +} + +func (hltp *hostedLoginTranslationProjection) reduceSet(e eventstore.Event) (*handler.Statement, error) { + + switch e := e.(type) { + case *org.HostedLoginTranslationSetEvent: + orgEvent := *e + return handler.NewUpsertStatement( + &orgEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, orgEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, orgEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, orgEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, orgEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, orgEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, orgEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, orgEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, orgEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(orgEvent.Translation)), + }, + ), nil + case *instance.HostedLoginTranslationSetEvent: + instanceEvent := *e + return handler.NewUpsertStatement( + &instanceEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, instanceEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, instanceEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, instanceEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, instanceEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, instanceEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, instanceEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, instanceEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, instanceEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(instanceEvent.Translation)), + }, + ), nil + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-AZshaa", "reduce.wrong.event.type %v", []eventstore.EventType{org.HostedLoginTranslationSet}) + } + +} + +func (hltp *hostedLoginTranslationProjection) computeEtag(translation map[string]any) string { + hash := md5.Sum(fmt.Append(nil, translation)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 77a28ac79a..5ad62380ea 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -86,6 +86,7 @@ var ( UserSchemaProjection *handler.Handler WebKeyProjection *handler.Handler DebugEventsProjection *handler.Handler + HostedLoginTranslationProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -179,6 +180,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, UserSchemaProjection = newUserSchemaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_schemas"])) WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"])) DebugEventsProjection = newDebugEventsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["debug_events"])) + HostedLoginTranslationProjection = newHostedLoginTranslationProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["hosted_login_translation"])) ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) @@ -357,5 +359,6 @@ func newProjectionsList() { UserSchemaProjection, WebKeyProjection, DebugEventsProjection, + HostedLoginTranslationProjection, } } diff --git a/internal/query/v2-default.json b/internal/query/v2-default.json new file mode 100644 index 0000000000..c86396ef34 --- /dev/null +++ b/internal/query/v2-default.json @@ -0,0 +1,1557 @@ +{ + "de":{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + } + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "success": "Erfolgreich verifiziert", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } + }, + "en":{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + } + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "success": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } + }, + "es":{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + } + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "success": "¡Verificación exitosa!", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } + }, + "it":{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + } + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "success": "Verifica effettuata con successo!", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } + + }, + "pl":{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + } + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "success": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } + }, + "ru":{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + } + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "success": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } + }, + "zh":{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + } + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "success": "用户验证成功。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } + } +} \ No newline at end of file diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index 68621597a8..b8089152bb 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -130,4 +130,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainAddedEventType, eventstore.GenericEventMapper[TrustedDomainAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainRemovedEventType, eventstore.GenericEventMapper[TrustedDomainRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/instance/hosted_login_translation.go b/internal/repository/instance/hosted_login_translation.go new file mode 100644 index 0000000000..05380521fc --- /dev/null +++ b/internal/repository/instance/hosted_login_translation.go @@ -0,0 +1,55 @@ +package instance + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = instanceEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "INST-lOxtJJ", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index d1efa75dfc..289bbbc608 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -114,4 +114,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyRemovedEventType, NotificationPolicyRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/org/hosted_login_translation.go b/internal/repository/org/hosted_login_translation.go new file mode 100644 index 0000000000..e07bdc1e3b --- /dev/null +++ b/internal/repository/org/hosted_login_translation.go @@ -0,0 +1,55 @@ +package org + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = orgEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "ORG-BH82Eb", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/proto/zitadel/settings/v2/settings.proto b/proto/zitadel/settings/v2/settings.proto index b3ca5b5ca5..c797d27965 100644 --- a/proto/zitadel/settings/v2/settings.proto +++ b/proto/zitadel/settings/v2/settings.proto @@ -10,4 +10,4 @@ enum ResourceOwnerType { RESOURCE_OWNER_TYPE_UNSPECIFIED = 0; RESOURCE_OWNER_TYPE_INSTANCE = 1; RESOURCE_OWNER_TYPE_ORG = 2; -} +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 7f71e08da4..0a1f13e7e7 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -15,6 +15,8 @@ import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "zitadel/settings/v2/settings.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; @@ -362,6 +364,69 @@ service SettingsService { description: "Set the security settings of the ZITADEL instance." }; } + + // Get Hosted Login Translation + // + // Returns the translations in the requested locale for the hosted login. + // The translations returned are based on the input level specified (system, instance or organization). + // + // If the requested level doesn't contain all translations, and ignore_inheritance is set to false, + // a merging process fallbacks onto the higher levels ensuring all keys in the file have a translation, + // which could be in the default language if the one of the locale is missing on all levels. + // + // The etag returned in the response represents the hash of the translations as they are stored on DB + // and its reliable only if ignore_inheritance = true. + // + // Required permissions: + // - `iam.policy.read` + rpc GetHostedLoginTranslation(GetHostedLoginTranslationRequest) returns (GetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The localized translations."; + } + }; + }; + + option (google.api.http) = { + get: "/v2/settings/hosted_login_translation" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.read" + } + }; + } + + // Set Hosted Login Translation + // + // Sets the input translations at the specified level (instance or organization) for the input language. + // + // Required permissions: + // - `iam.policy.write` + rpc SetHostedLoginTranslation(SetHostedLoginTranslationRequest) returns (SetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The translations was successfully set."; + } + }; + }; + + option (google.api.http) = { + put: "/v2/settings/hosted_login_translation"; + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.write" + } + }; + } } message GetLoginSettingsRequest { @@ -480,4 +545,76 @@ message SetSecuritySettingsRequest{ message SetSecuritySettingsResponse{ zitadel.object.v2.Details details = 1; +} + +message GetHostedLoginTranslationRequest { + oneof level { + bool system = 1 [(validate.rules).bool = {const: true}]; + bool instance = 2 [(validate.rules).bool = {const: true}]; + string organization_id = 3; + } + + string locale = 4 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + // if set to true, higher levels are ignored, if false higher levels are merged into the file + bool ignore_inheritance = 5; +} + +message GetHostedLoginTranslationResponse { + // hash of the payload + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; + + google.protobuf.Struct translations = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations contains the translations in the request language."; + } + ]; +} + +message SetHostedLoginTranslationRequest { + oneof level { + bool instance = 1 [(validate.rules).bool = {const: true}]; + string organization_id = 2; + } + + string locale = 3 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + google.protobuf.Struct translations = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations should contain the translations in the specified locale."; + } + ]; +} + +message SetHostedLoginTranslationResponse { + // hash of the saved translation. Valid only when ignore_inheritance = true + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; } \ No newline at end of file