From 2fdc3c2e3ba00d4a8cda7b1dee7b32afd784a9bf Mon Sep 17 00:00:00 2001 From: Michelle Bergquist <11967646+michellescripts@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:16:38 -0600 Subject: [PATCH] Leverage marketing params on Discover (#31648) * marketing params to cluster state * leverage marketing params on Discover --- .../proto/go/userpreferences/v1/onboard.pb.go | 186 +- .../teleport/userpreferences/v1/onboard.proto | 16 + .../userpreferencesv1/service_test.go | 1 + lib/services/local/userpreferences.go | 1 + lib/services/local/userpreferences_test.go | 27 +- lib/web/userpreferences.go | 24 +- .../teleport/src/Discover/Discover.test.tsx | 13 +- .../SelectResource/SelectResource.story.tsx | 2 +- .../SelectResource/SelectResource.test.tsx | 542 ++++- .../SelectResource/SelectResource.tsx | 91 +- .../SelectResource.story.test.tsx.snap | 1904 ++++++++--------- .../getMarketingMatches.test.ts | 163 ++ .../SelectResource/getMarketingTermMatches.ts | 91 + .../src/Discover/SelectResource/types.ts | 7 + .../src/services/localStorage/localStorage.ts | 10 +- .../src/services/localStorage/types.ts | 11 +- .../src/services/userPreferences/types.ts | 8 + .../userPreferences/userPreferences.ts | 8 +- 18 files changed, 1967 insertions(+), 1138 deletions(-) create mode 100644 web/packages/teleport/src/Discover/SelectResource/getMarketingMatches.test.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/getMarketingTermMatches.ts diff --git a/api/gen/proto/go/userpreferences/v1/onboard.pb.go b/api/gen/proto/go/userpreferences/v1/onboard.pb.go index 2a50168f785ea..785cd0ceab0cb 100644 --- a/api/gen/proto/go/userpreferences/v1/onboard.pb.go +++ b/api/gen/proto/go/userpreferences/v1/onboard.pb.go @@ -93,6 +93,84 @@ func (Resource) EnumDescriptor() ([]byte, []int) { return file_teleport_userpreferences_v1_onboard_proto_rawDescGZIP(), []int{0} } +// MarketingParams are the parameters associated with a user via marketing campaign at the time of sign up. +// They contain both traditional Urchin Tracking Module (UTM) parameters as well as custom parameters. +type MarketingParams struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // campaign is the UTM campaign parameter which identifies a specific product promotion + Campaign string `protobuf:"bytes,1,opt,name=campaign,proto3" json:"campaign,omitempty"` + // source is the UTM source parameter which identifies which site sent the traffic + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + // medium is the UTM medium parameter which identifies what type of link was used + Medium string `protobuf:"bytes,3,opt,name=medium,proto3" json:"medium,omitempty"` + // intent is the internal query param, which identifies any additional marketing intentions + // via internally set and directed parameters. + Intent string `protobuf:"bytes,4,opt,name=intent,proto3" json:"intent,omitempty"` +} + +func (x *MarketingParams) Reset() { + *x = MarketingParams{} + if protoimpl.UnsafeEnabled { + mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MarketingParams) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarketingParams) ProtoMessage() {} + +func (x *MarketingParams) ProtoReflect() protoreflect.Message { + mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarketingParams.ProtoReflect.Descriptor instead. +func (*MarketingParams) Descriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_onboard_proto_rawDescGZIP(), []int{0} +} + +func (x *MarketingParams) GetCampaign() string { + if x != nil { + return x.Campaign + } + return "" +} + +func (x *MarketingParams) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MarketingParams) GetMedium() string { + if x != nil { + return x.Medium + } + return "" +} + +func (x *MarketingParams) GetIntent() string { + if x != nil { + return x.Intent + } + return "" +} + // OnboardUserPreferences is the user preferences selected during onboarding. type OnboardUserPreferences struct { state protoimpl.MessageState @@ -101,12 +179,14 @@ type OnboardUserPreferences struct { // preferredResources is an array of the resources a user selected during their onboarding questionnaire. PreferredResources []Resource `protobuf:"varint,1,rep,packed,name=preferred_resources,json=preferredResources,proto3,enum=teleport.userpreferences.v1.Resource" json:"preferred_resources,omitempty"` + // marketingParams are the parameters associated with a user via marketing campaign at the time of sign up + MarketingParams *MarketingParams `protobuf:"bytes,2,opt,name=marketing_params,json=marketingParams,proto3" json:"marketing_params,omitempty"` } func (x *OnboardUserPreferences) Reset() { *x = OnboardUserPreferences{} if protoimpl.UnsafeEnabled { - mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[0] + mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -119,7 +199,7 @@ func (x *OnboardUserPreferences) String() string { func (*OnboardUserPreferences) ProtoMessage() {} func (x *OnboardUserPreferences) ProtoReflect() protoreflect.Message { - mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[0] + mi := &file_teleport_userpreferences_v1_onboard_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -132,7 +212,7 @@ func (x *OnboardUserPreferences) ProtoReflect() protoreflect.Message { // Deprecated: Use OnboardUserPreferences.ProtoReflect.Descriptor instead. func (*OnboardUserPreferences) Descriptor() ([]byte, []int) { - return file_teleport_userpreferences_v1_onboard_proto_rawDescGZIP(), []int{0} + return file_teleport_userpreferences_v1_onboard_proto_rawDescGZIP(), []int{1} } func (x *OnboardUserPreferences) GetPreferredResources() []Resource { @@ -142,6 +222,13 @@ func (x *OnboardUserPreferences) GetPreferredResources() []Resource { return nil } +func (x *OnboardUserPreferences) GetMarketingParams() *MarketingParams { + if x != nil { + return x.MarketingParams + } + return nil +} + var File_teleport_userpreferences_v1_onboard_proto protoreflect.FileDescriptor var file_teleport_userpreferences_v1_onboard_proto_rawDesc = []byte{ @@ -149,31 +236,44 @@ var file_teleport_userpreferences_v1_onboard_proto_rawDesc = []byte{ 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1b, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, - 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x70, 0x0a, 0x16, 0x4f, 0x6e, 0x62, 0x6f, - 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x12, 0x56, 0x0a, 0x13, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0e, 0x32, - 0x25, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, - 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x12, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, - 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2a, 0xac, 0x01, 0x0a, 0x08, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x52, 0x45, 0x53, 0x4f, 0x55, - 0x52, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, - 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x57, 0x49, - 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x5f, 0x44, 0x45, 0x53, 0x4b, 0x54, 0x4f, 0x50, 0x53, 0x10, 0x01, - 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x53, 0x45, 0x52, - 0x56, 0x45, 0x52, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x02, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x45, 0x53, - 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x44, 0x41, 0x54, 0x41, 0x42, 0x41, 0x53, 0x45, 0x53, 0x10, - 0x03, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x4b, 0x55, - 0x42, 0x45, 0x52, 0x4e, 0x45, 0x54, 0x45, 0x53, 0x10, 0x04, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, - 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x57, 0x45, 0x42, 0x5f, 0x41, 0x50, 0x50, 0x4c, 0x49, - 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x53, 0x10, 0x05, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, - 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, - 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, - 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x75, 0x0a, 0x0f, 0x4d, 0x61, 0x72, 0x6b, + 0x65, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, + 0x61, 0x6d, 0x70, 0x61, 0x69, 0x67, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, + 0x61, 0x6d, 0x70, 0x61, 0x69, 0x67, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, + 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x64, 0x69, 0x75, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x6d, 0x65, 0x64, 0x69, 0x75, 0x6d, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, + 0xc9, 0x01, 0x0a, 0x16, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x56, 0x0a, 0x13, 0x70, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x12, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x73, 0x12, 0x57, 0x0a, 0x10, 0x6d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x69, 0x6e, 0x67, 0x5f, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x74, + 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x72, 0x6b, 0x65, + 0x74, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x52, 0x0f, 0x6d, 0x61, 0x72, 0x6b, + 0x65, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x2a, 0xac, 0x01, 0x0a, 0x08, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x52, 0x45, 0x53, 0x4f, + 0x55, 0x52, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, + 0x10, 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x57, + 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x5f, 0x44, 0x45, 0x53, 0x4b, 0x54, 0x4f, 0x50, 0x53, 0x10, + 0x01, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x53, 0x45, + 0x52, 0x56, 0x45, 0x52, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x02, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x45, + 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x44, 0x41, 0x54, 0x41, 0x42, 0x41, 0x53, 0x45, 0x53, + 0x10, 0x03, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x4b, + 0x55, 0x42, 0x45, 0x52, 0x4e, 0x45, 0x54, 0x45, 0x53, 0x10, 0x04, 0x12, 0x1d, 0x0a, 0x19, 0x52, + 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x57, 0x45, 0x42, 0x5f, 0x41, 0x50, 0x50, 0x4c, + 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x53, 0x10, 0x05, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, + 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, + 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -189,18 +289,20 @@ func file_teleport_userpreferences_v1_onboard_proto_rawDescGZIP() []byte { } var file_teleport_userpreferences_v1_onboard_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_userpreferences_v1_onboard_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_teleport_userpreferences_v1_onboard_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_teleport_userpreferences_v1_onboard_proto_goTypes = []interface{}{ (Resource)(0), // 0: teleport.userpreferences.v1.Resource - (*OnboardUserPreferences)(nil), // 1: teleport.userpreferences.v1.OnboardUserPreferences + (*MarketingParams)(nil), // 1: teleport.userpreferences.v1.MarketingParams + (*OnboardUserPreferences)(nil), // 2: teleport.userpreferences.v1.OnboardUserPreferences } var file_teleport_userpreferences_v1_onboard_proto_depIdxs = []int32{ 0, // 0: teleport.userpreferences.v1.OnboardUserPreferences.preferred_resources:type_name -> teleport.userpreferences.v1.Resource - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 1, // 1: teleport.userpreferences.v1.OnboardUserPreferences.marketing_params:type_name -> teleport.userpreferences.v1.MarketingParams + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_teleport_userpreferences_v1_onboard_proto_init() } @@ -210,6 +312,18 @@ func file_teleport_userpreferences_v1_onboard_proto_init() { } if !protoimpl.UnsafeEnabled { file_teleport_userpreferences_v1_onboard_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MarketingParams); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_teleport_userpreferences_v1_onboard_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*OnboardUserPreferences); i { case 0: return &v.state @@ -228,7 +342,7 @@ func file_teleport_userpreferences_v1_onboard_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_teleport_userpreferences_v1_onboard_proto_rawDesc, NumEnums: 1, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/api/proto/teleport/userpreferences/v1/onboard.proto b/api/proto/teleport/userpreferences/v1/onboard.proto index 9df0ec0d04780..352f0366b1820 100644 --- a/api/proto/teleport/userpreferences/v1/onboard.proto +++ b/api/proto/teleport/userpreferences/v1/onboard.proto @@ -28,8 +28,24 @@ enum Resource { RESOURCE_WEB_APPLICATIONS = 5; } +// MarketingParams are the parameters associated with a user via marketing campaign at the time of sign up. +// They contain both traditional Urchin Tracking Module (UTM) parameters as well as custom parameters. +message MarketingParams { + // campaign is the UTM campaign parameter which identifies a specific product promotion + string campaign = 1; + // source is the UTM source parameter which identifies which site sent the traffic + string source = 2; + // medium is the UTM medium parameter which identifies what type of link was used + string medium = 3; + // intent is the internal query param, which identifies any additional marketing intentions + // via internally set and directed parameters. + string intent = 4; +} + // OnboardUserPreferences is the user preferences selected during onboarding. message OnboardUserPreferences { // preferredResources is an array of the resources a user selected during their onboarding questionnaire. repeated Resource preferred_resources = 1; + // marketingParams are the parameters associated with a user via marketing campaign at the time of sign up + MarketingParams marketing_params = 2; } diff --git a/lib/auth/userpreferences/userpreferencesv1/service_test.go b/lib/auth/userpreferences/userpreferencesv1/service_test.go index 6b15f8980fd89..ed27de5e32126 100644 --- a/lib/auth/userpreferences/userpreferencesv1/service_test.go +++ b/lib/auth/userpreferences/userpreferencesv1/service_test.go @@ -60,6 +60,7 @@ func TestService_GetUserPreferences(t *testing.T) { Theme: userpreferencesv1.Theme_THEME_LIGHT, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{}, + MarketingParams: &userpreferencesv1.MarketingParams{}, }, }, }, diff --git a/lib/services/local/userpreferences.go b/lib/services/local/userpreferences.go index be8e5fd5d5bb5..45053e9d61a90 100644 --- a/lib/services/local/userpreferences.go +++ b/lib/services/local/userpreferences.go @@ -43,6 +43,7 @@ func DefaultUserPreferences() *userpreferencesv1.UserPreferences { Theme: userpreferencesv1.Theme_THEME_LIGHT, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{}, + MarketingParams: &userpreferencesv1.MarketingParams{}, }, } } diff --git a/lib/services/local/userpreferences_test.go b/lib/services/local/userpreferences_test.go index dcfeace7ee75a..2794eabeee0c5 100644 --- a/lib/services/local/userpreferences_test.go +++ b/lib/services/local/userpreferences_test.go @@ -43,7 +43,7 @@ func newUserPreferencesService(t *testing.T) *local.UserPreferencesService { return local.NewUserPreferencesService(backend) } -func TestUserPreferencesCRUD2(t *testing.T) { +func TestUserPreferencesCRUD(t *testing.T) { t.Parallel() ctx := context.Background() @@ -82,6 +82,7 @@ func TestUserPreferencesCRUD2(t *testing.T) { }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{}, + MarketingParams: &userpreferencesv1.MarketingParams{}, }, }, }, @@ -118,6 +119,12 @@ func TestUserPreferencesCRUD2(t *testing.T) { Preferences: &userpreferencesv1.UserPreferences{ Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{userpreferencesv1.Resource_RESOURCE_DATABASES}, + MarketingParams: &userpreferencesv1.MarketingParams{ + Campaign: "c_1", + Source: "s_1", + Medium: "m_1", + Intent: "i_1", + }, }, }, }, @@ -126,6 +133,12 @@ func TestUserPreferencesCRUD2(t *testing.T) { Theme: defaultPref.Theme, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{userpreferencesv1.Resource_RESOURCE_DATABASES}, + MarketingParams: &userpreferencesv1.MarketingParams{ + Campaign: "c_1", + Source: "s_1", + Medium: "m_1", + Intent: "i_1", + }, }, }, }, @@ -140,6 +153,12 @@ func TestUserPreferencesCRUD2(t *testing.T) { }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{userpreferencesv1.Resource_RESOURCE_KUBERNETES}, + MarketingParams: &userpreferencesv1.MarketingParams{ + Campaign: "c_2", + Source: "s_2", + Medium: "m_2", + Intent: "i_2", + }, }, }, }, @@ -151,6 +170,12 @@ func TestUserPreferencesCRUD2(t *testing.T) { }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{userpreferencesv1.Resource_RESOURCE_KUBERNETES}, + MarketingParams: &userpreferencesv1.MarketingParams{ + Campaign: "c_2", + Source: "s_2", + Medium: "m_2", + Intent: "i_2", + }, }, }, }, diff --git a/lib/web/userpreferences.go b/lib/web/userpreferences.go index 0c247d4862e0a..ececb5af1e9ed 100644 --- a/lib/web/userpreferences.go +++ b/lib/web/userpreferences.go @@ -32,8 +32,16 @@ type AssistUserPreferencesResponse struct { ViewMode userpreferencesv1.AssistViewMode `json:"viewMode"` } +type preferencesMarketingParams struct { + Campaign string `json:"campaign"` + Source string `json:"source"` + Medium string `json:"medium"` + Intent string `json:"intent"` +} + type OnboardUserPreferencesResponse struct { PreferredResources []userpreferencesv1.Resource `json:"preferredResources"` + MarketingParams preferencesMarketingParams `json:"marketingParams"` } // UserPreferencesResponse is the JSON response for the user preferences. @@ -44,7 +52,7 @@ type UserPreferencesResponse struct { } // getUserPreferences is a handler for GET /webapi/user/preferences -func (h *Handler) getUserPreferences(_ http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext) (any, error) { +func (h *Handler) getUserPreferences(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext) (any, error) { authClient, err := sctx.GetClient() if err != nil { return nil, trace.Wrap(err) @@ -59,7 +67,7 @@ func (h *Handler) getUserPreferences(_ http.ResponseWriter, r *http.Request, p h } // updateUserPreferences is a handler for PUT /webapi/user/preferences. -func (h *Handler) updateUserPreferences(_ http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext) (any, error) { +func (h *Handler) updateUserPreferences(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext) (any, error) { req := UserPreferencesResponse{} if err := httplib.ReadJSON(r, &req); err != nil { @@ -80,6 +88,12 @@ func (h *Handler) updateUserPreferences(_ http.ResponseWriter, r *http.Request, }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: req.Onboard.PreferredResources, + MarketingParams: &userpreferencesv1.MarketingParams{ + Campaign: req.Onboard.MarketingParams.Campaign, + Source: req.Onboard.MarketingParams.Source, + Medium: req.Onboard.MarketingParams.Medium, + Intent: req.Onboard.MarketingParams.Intent, + }, }, }, } @@ -118,6 +132,12 @@ func assistUserPreferencesResponse(resp *userpreferencesv1.AssistUserPreferences func onboardUserPreferencesResponse(resp *userpreferencesv1.OnboardUserPreferences) OnboardUserPreferencesResponse { jsonResp := OnboardUserPreferencesResponse{ PreferredResources: make([]userpreferencesv1.Resource, 0, len(resp.PreferredResources)), + MarketingParams: preferencesMarketingParams{ + Campaign: resp.MarketingParams.Campaign, + Source: resp.MarketingParams.Source, + Medium: resp.MarketingParams.Medium, + Intent: resp.MarketingParams.Intent, + }, } jsonResp.PreferredResources = append(jsonResp.PreferredResources, resp.PreferredResources...) diff --git a/web/packages/teleport/src/Discover/Discover.test.tsx b/web/packages/teleport/src/Discover/Discover.test.tsx index af6bb4b2f06a4..ec46c1dac965e 100644 --- a/web/packages/teleport/src/Discover/Discover.test.tsx +++ b/web/packages/teleport/src/Discover/Discover.test.tsx @@ -54,15 +54,12 @@ type createProps = { const create = ({ initialEntry = '', preferredResource }: createProps) => { const defaultPref = makeDefaultUserPreferences(); + defaultPref.onboard.preferredResources = preferredResource + ? [preferredResource] + : []; + mockUserContextProviderWith( - makeTestUserContext({ - preferences: { - ...defaultPref, - onboard: { - preferredResources: preferredResource ? [preferredResource] : [], - }, - }, - }) + makeTestUserContext({ preferences: defaultPref }) ); const userAcl = getAcl(); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx index 1e6dab9e29fdb..c8a5f62091269 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx @@ -97,7 +97,7 @@ const Provider = ({ const ctx = createTeleportContext({ customAcl: customAcl }); const updatePreferences = () => Promise.resolve(); const preferences: UserPreferences = makeDefaultUserPreferences(); - preferences.onboard = { preferredResources: resources }; + preferences.onboard.preferredResources = resources; return ( { @@ -103,137 +199,318 @@ describe('preferred resources', () => { const testCases: { name: string; - preferred: OnboardUserPreferences; + preferred: ClusterResource[]; expected: ResourceSpec[]; }[] = [ { name: 'preferred server/ssh', + preferred: [ClusterResource.RESOURCE_SERVER_SSH], + expected: [ + // preferred first + f_Server, + h_Server, + // alpha; guided before unguided + a_Database, + c_Application, + d_Saml, + g_Application, + i_Desktop, + j_Kubernetes, + k_Database, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, + ], + }, + { + name: 'preferred databases', + preferred: [ClusterResource.RESOURCE_DATABASES], + expected: [ + // preferred first + a_Database, + k_Database, + // alpha; guided before unguided + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + i_Desktop, + j_Kubernetes, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, + ], + }, + { + name: 'preferred windows', + preferred: [ClusterResource.RESOURCE_WINDOWS_DESKTOPS], + expected: [ + // preferred first + i_Desktop, + l_Desktop, + // alpha; guided before unguided + a_Database, + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + j_Kubernetes, + k_Database, + l_Saml, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, + ], + }, + { + name: 'preferred applications', + preferred: [ClusterResource.RESOURCE_WEB_APPLICATIONS], + expected: [ + // preferred first + c_Application, + g_Application, + // alpha; guided before unguided + a_Database, + d_Saml, + f_Server, + h_Server, + i_Desktop, + j_Kubernetes, + k_Database, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, + ], + }, + { + name: 'preferred kubernetes', + preferred: [ClusterResource.RESOURCE_KUBERNETES], + expected: [ + // preferred first; guided before unguided + j_Kubernetes, + e_Kubernetes_unguided, + // alpha + a_Database, + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + i_Desktop, + k_Database, + l_Saml, + l_Desktop, + // no access is last + ...NoAccessList, + ], + }, + ]; + + test.each(testCases)('$name', testCase => { + const preferences = makeDefaultUserPreferences(); + preferences.onboard.preferredResources = testCase.preferred; + const actual = sortResources(kindBasedList, preferences); + + expect(actual).toMatchObject(testCase.expected); + }); +}); + +describe('marketing params', () => { + beforeEach(() => { + setUp(); + }); + + const testCases: { + name: string; + preferred: OnboardUserPreferences; + expected: ResourceSpec[]; + }[] = [ + { + name: 'marketing params instead of preferred resources', preferred: { - preferredResources: [ClusterResource.RESOURCE_SERVER_SSH], + preferredResources: [ClusterResource.RESOURCE_WEB_APPLICATIONS], + marketingParams: { + campaign: 'kubernetes', + source: '', + medium: '', + intent: '', + }, + }, + expected: [ + // marketing params first; no preferred priority, guided before unguided + j_Kubernetes, + e_Kubernetes_unguided, + // alpha + a_Database, + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + i_Desktop, + k_Database, + l_Saml, + l_Desktop, + // no access is last + ...NoAccessList, + ], + }, + { + name: 'param server/ssh', + preferred: { + preferredResources: [], + marketingParams: { + campaign: 'ssh', + source: '', + medium: '', + intent: '', + }, }, expected: [ // preferred first - makeResourceSpec({ name: 'foxtrot', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }), + f_Server, + h_Server, // alpha; guided before unguided - makeResourceSpec({ name: 'alpha', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'charlie', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'delta', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'golf', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'india', kind: ResourceKind.Desktop }), - makeResourceSpec({ name: 'juliette', kind: ResourceKind.Kubernetes }), - makeResourceSpec({ name: 'kilo', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'lima', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'linux', kind: ResourceKind.Desktop }), - makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', - }), + a_Database, + c_Application, + d_Saml, + g_Application, + i_Desktop, + j_Kubernetes, + k_Database, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, ], }, { - name: 'preferred databases', + name: 'param databases', preferred: { - preferredResources: [ClusterResource.RESOURCE_DATABASES], + preferredResources: [], + marketingParams: { + campaign: '', + source: 'database', + medium: '', + intent: '', + }, }, expected: [ // preferred first - makeResourceSpec({ name: 'alpha', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'kilo', kind: ResourceKind.Database }), + a_Database, + k_Database, // alpha; guided before unguided - makeResourceSpec({ name: 'charlie', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'delta', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'foxtrot', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'golf', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'india', kind: ResourceKind.Desktop }), - makeResourceSpec({ name: 'juliette', kind: ResourceKind.Kubernetes }), - makeResourceSpec({ name: 'lima', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'linux', kind: ResourceKind.Desktop }), - makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', - }), + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + i_Desktop, + j_Kubernetes, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, ], }, { - name: 'preferred windows', + name: 'param windows', preferred: { - preferredResources: [ClusterResource.RESOURCE_WINDOWS_DESKTOPS], + preferredResources: [], + marketingParams: { + campaign: '', + source: '', + medium: 'windows', + intent: '', + }, }, expected: [ // preferred first - makeResourceSpec({ name: 'india', kind: ResourceKind.Desktop }), - makeResourceSpec({ name: 'linux', kind: ResourceKind.Desktop }), + i_Desktop, + l_Desktop, // alpha; guided before unguided - makeResourceSpec({ name: 'alpha', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'charlie', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'delta', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'foxtrot', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'golf', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'juliette', kind: ResourceKind.Kubernetes }), - makeResourceSpec({ name: 'kilo', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'lima', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', - }), + a_Database, + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + j_Kubernetes, + k_Database, + l_Saml, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, ], }, { - name: 'preferred applications', + name: 'param applications', preferred: { - preferredResources: [ClusterResource.RESOURCE_WEB_APPLICATIONS], + preferredResources: [], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: 'application', + }, }, expected: [ // preferred first - makeResourceSpec({ name: 'charlie', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'golf', kind: ResourceKind.Application }), + c_Application, + g_Application, // alpha; guided before unguided - makeResourceSpec({ name: 'alpha', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'delta', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'foxtrot', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'india', kind: ResourceKind.Desktop }), - makeResourceSpec({ name: 'juliette', kind: ResourceKind.Kubernetes }), - makeResourceSpec({ name: 'kilo', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'lima', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'linux', kind: ResourceKind.Desktop }), - makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', - }), + a_Database, + d_Saml, + f_Server, + h_Server, + i_Desktop, + j_Kubernetes, + k_Database, + l_Saml, + l_Desktop, + e_Kubernetes_unguided, + // no access is last + ...NoAccessList, ], }, { - name: 'preferred kubernetes', + name: 'param kubernetes', preferred: { - preferredResources: [ClusterResource.RESOURCE_KUBERNETES], + preferredResources: [], + marketingParams: { + campaign: '', + source: '', + medium: 'k8s', + intent: '', + }, }, expected: [ // preferred first; guided before unguided - makeResourceSpec({ name: 'juliette', kind: ResourceKind.Kubernetes }), - makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', - }), + j_Kubernetes, + e_Kubernetes_unguided, // alpha - makeResourceSpec({ name: 'alpha', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'charlie', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'delta', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'foxtrot', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'golf', kind: ResourceKind.Application }), - makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }), - makeResourceSpec({ name: 'india', kind: ResourceKind.Desktop }), - makeResourceSpec({ name: 'kilo', kind: ResourceKind.Database }), - makeResourceSpec({ name: 'lima', kind: ResourceKind.SamlApplication }), - makeResourceSpec({ name: 'linux', kind: ResourceKind.Desktop }), + a_Database, + c_Application, + d_Saml, + f_Server, + g_Application, + h_Server, + i_Desktop, + k_Database, + l_Saml, + l_Desktop, + // no access is last + ...NoAccessList, ], }, ]; @@ -249,8 +526,18 @@ describe('preferred resources', () => { const osBasedList: ResourceSpec[] = [ makeResourceSpec({ name: 'Aaaa' }), + makeResourceSpec({ + name: 'no-linux-1', + platform: Platform.PLATFORM_LINUX, + hasAccess: false, + }), makeResourceSpec({ name: 'win', platform: Platform.PLATFORM_WINDOWS }), makeResourceSpec({ name: 'linux-2', platform: Platform.PLATFORM_LINUX }), + makeResourceSpec({ + name: 'no-mac', + platform: Platform.PLATFORM_MACINTOSH, + hasAccess: false, + }), makeResourceSpec({ name: 'mac', platform: Platform.PLATFORM_MACINTOSH }), makeResourceSpec({ name: 'linux-1', platform: Platform.PLATFORM_LINUX }), ]; @@ -287,6 +574,17 @@ describe('os sorted resources', () => { platform: Platform.PLATFORM_LINUX, }), makeResourceSpec({ name: 'win', platform: Platform.PLATFORM_WINDOWS }), + // no access, alpha + makeResourceSpec({ + name: 'no-linux-1', + platform: Platform.PLATFORM_LINUX, + hasAccess: false, + }), + makeResourceSpec({ + name: 'no-mac', + platform: Platform.PLATFORM_MACINTOSH, + hasAccess: false, + }), ], }, { @@ -309,6 +607,17 @@ describe('os sorted resources', () => { platform: Platform.PLATFORM_MACINTOSH, }), makeResourceSpec({ name: 'win', platform: Platform.PLATFORM_WINDOWS }), + // no access, alpha + makeResourceSpec({ + name: 'no-linux-1', + platform: Platform.PLATFORM_LINUX, + hasAccess: false, + }), + makeResourceSpec({ + name: 'no-mac', + platform: Platform.PLATFORM_MACINTOSH, + hasAccess: false, + }), ], }, { @@ -331,6 +640,17 @@ describe('os sorted resources', () => { name: 'mac', platform: Platform.PLATFORM_MACINTOSH, }), + // no access, alpha + makeResourceSpec({ + name: 'no-linux-1', + platform: Platform.PLATFORM_LINUX, + hasAccess: false, + }), + makeResourceSpec({ + name: 'no-mac', + platform: Platform.PLATFORM_MACINTOSH, + hasAccess: false, + }), ], }, ]; @@ -384,7 +704,15 @@ describe('os sorted resources', () => { test('all logic together', () => { OS.mockReturnValue(Platform.PLATFORM_MACINTOSH); const preferences = makeDefaultUserPreferences(); - preferences.onboard = { preferredResources: [2] }; + preferences.onboard = { + preferredResources: [2], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; const actual = sortResources(oneOfEachList, preferences); expect(actual).toMatchObject([ diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index f30711b2bb850..0604d73814d86 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -46,9 +46,10 @@ import { import { resourceKindToPreferredResource } from 'teleport/Discover/Shared/ResourceKind'; +import { getMarketingTermMatches } from './getMarketingTermMatches'; import { DiscoverIcon } from './icons'; -import { SearchResource } from './types'; +import { PrioritizedResources, SearchResource } from './types'; import type { ResourceSpec } from './types'; @@ -89,16 +90,11 @@ export function SelectResource({ onSelect }: SelectResourceProps) { // Apply access check to each resource. const userContext = ctx.storeUser.state; const { acl } = userContext; - const updatedResources = makeResourcesWithHasAccessField(acl); - - // Sort resources that user has access to the - // top of the list, so it is more visible to - // the user. - const filteredResourcesByPerm = [ - ...updatedResources.filter(r => r.hasAccess), - ...updatedResources.filter(r => !r.hasAccess), - ]; - const sortedResources = sortResources(filteredResourcesByPerm, preferences); + + const sortedResources = sortResources( + makeResourcesWithHasAccessField(acl), + preferences + ); setDefaultResources(sortedResources); // A user can come to this screen by clicking on @@ -339,31 +335,22 @@ function sortResourcesByKind( return sorted; } -// Sort the resources by 1. preferred 2. guided and 3. alphabetically export function sortResources( resources: ResourceSpec[], preferences: UserPreferences ) { - // A user can have preferredResources set via their onboarding survey. - // We sort the list by the preferred resource type but do not apply a search. - const preferredResources = - (preferences && - preferences.onboard && - preferences.onboard.preferredResources) || - []; - const hasPreferredResources = - preferredResources && preferredResources.length > 0; - const maxResources = Object.keys(ClusterResource).length / 2 - 1; - const selectedAllResources = preferredResources.length === maxResources; + const { preferredResources, hasPreferredResources } = + getPrioritizedResources(preferences); const sortedResources = [...resources]; - sortedResources.sort((a, b) => { - if (!a.hasAccess) return -1; - if (!b.hasAccess) return -1; + const accessible = sortedResources.filter(r => r.hasAccess); + const restricted = sortedResources.filter(r => !r.hasAccess); + // Sort accessible resources by 1. os 2. preferred 3. guided and 4. alphabetically + accessible.sort((a, b) => { let aPreferred, bPreferred = false; - if (hasPreferredResources && !selectedAllResources) { + if (hasPreferredResources) { aPreferred = preferredResources.includes( resourceKindToPreferredResource(a.kind) ); @@ -415,7 +402,55 @@ export function sortResources( return a.name.localeCompare(b.name); }); - return sortedResources; + // Sort restricted resources alphabetically + restricted.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + + // Sort resources that user has access to the + // top of the list, so it is more visible to + // the user. + return [...accessible, ...restricted]; +} + +/** + * Returns prioritized resources based on user preferences cluster state + * + * @remarks + * A user can have preferredResources set via onboarding either from the survey (preferredResources) + * or various query parameters (marketingParams). We sort the list by the marketingParams if available. + * If not, we sort by preferred resource type if available. + * We do not search. + * + * @param preferences - Cluster state user preferences + * @returns PrioritizedResources which is both the resource to prioritize and a boolean value of the value + * + */ +function getPrioritizedResources( + preferences: UserPreferences +): PrioritizedResources { + const marketingParams = preferences.onboard.marketingParams; + + if (marketingParams) { + const marketingPriorities = getMarketingTermMatches(marketingParams); + if (marketingPriorities.length > 0) { + return { + hasPreferredResources: true, + preferredResources: marketingPriorities, + }; + } + } + + const preferredResources = preferences.onboard.preferredResources || []; + + // hasPreferredResources will be false if all resources are selected + const maxResources = Object.keys(ClusterResource).length / 2 - 1; + const selectedAll = preferredResources.length === maxResources; + + return { + preferredResources: preferredResources, + hasPreferredResources: preferredResources.length > 0 && !selectedAll, + }; } function makeResourcesWithHasAccessField(acl: Acl): ResourceSpec[] { diff --git a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap index 765f123caf65a..085a032d4a194 100644 --- a/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/SelectResource/__snapshots__/SelectResource.story.test.tsx.snap @@ -2340,7 +2340,7 @@ exports[`render with no access 1`] = ` width: 600px; } -.c13 { +.c12 { box-sizing: border-box; } @@ -2361,7 +2361,7 @@ exports[`render with no access 1`] = ` margin-bottom: 32px; } -.c14 { +.c13 { overflow: hidden; text-overflow: ellipsis; font-size: 12px; @@ -2369,12 +2369,11 @@ exports[`render with no access 1`] = ` color: rgba(255,255,255,0.72); } -.c15 { +.c14 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; margin: 0px; - color: #FFFFFF; } .c16 { @@ -2382,6 +2381,7 @@ exports[`render with no access 1`] = ` text-overflow: ellipsis; font-weight: 600; margin: 0px; + color: #FFFFFF; } .c17 { @@ -2392,7 +2392,7 @@ exports[`render with no access 1`] = ` margin-top: 40px; } -.c7 { +.c15 { color: #009EFF; font-weight: normal; background: none; @@ -2409,14 +2409,14 @@ exports[`render with no access 1`] = ` margin-left: 8px; } -.c12 { +.c11 { display: block; outline: none; width: 23.9px; height: 24px; } -.c10 { +.c9 { box-sizing: border-box; padding-left: 8px; padding-right: 8px; @@ -2424,7 +2424,7 @@ exports[`render with no access 1`] = ` align-items: center; } -.c11 { +.c10 { box-sizing: border-box; margin-right: 16px; width: 24px; @@ -2432,7 +2432,7 @@ exports[`render with no access 1`] = ` justify-content: center; } -.c9 { +.c8 { box-sizing: border-box; border-top-right-radius: 4px; border-bottom-left-radius: 4px; @@ -2451,7 +2451,7 @@ exports[`render with no access 1`] = ` row-gap: 15px; } -.c8 { +.c7 { display: flex; position: relative; align-items: center; @@ -2465,7 +2465,7 @@ exports[`render with no access 1`] = ` opacity: 0.45; } -.c8:hover { +.c7:hover { background: rgba(255,255,255,0.13); } @@ -2529,1249 +2529,1214 @@ exports[`render with no access 1`] = `
-
Lacking Permissions
- Database + Windows Desktop
- Dynamic Registration + Active Directory
-
- +
Lacking Permissions
- Database + Server
- High Availability + Amazon Linux 2/2023
-
- +
Lacking Permissions
+
- Amazon Web Services (AWS) -
-
- RDS Proxy + Application
-
- +
Lacking Permissions
- Google Cloud Provider (GCP) + Database
- Cloud SQL PostgreSQL + Dynamic Registration
Lacking Permissions
- Google Cloud Provider (GCP) + Amazon Web Services (AWS)
- Cloud SQL MySQL + DynamoDB
Lacking Permissions
- Microsoft + Amazon Web Services (AWS)
- SQL Server (Preview) + ElastiCache & MemoryDB
Lacking Permissions
- Azure + Self-Hosted
- SQL Server (Preview) + Elasticsearch
Lacking Permissions
- Azure + Database
- MySQL + High Availability
Lacking Permissions
- Azure + Amazon Web Services (AWS)
- PostgreSQL + Keyspaces (Apache Cassandra)
-
Lacking Permissions
+
- Azure -
-
- Cache for Redis + Kubernetes
-
- +
Lacking Permissions
- Amazon Web Services (AWS) + Self-Hosted
- Redshift PostgreSQL + MongoDB
Lacking Permissions
+
- Amazon Web Services (AWS) -
-
- Keyspaces (Apache Cassandra) + MongoDB Atlas
Lacking Permissions
- Amazon Web Services (AWS) + Azure
- ElastiCache & MemoryDB + MySQL
-
Lacking Permissions
- Amazon Web Services (AWS) + Self-Hosted
- DynamoDB + MySQL/MariaDB
-
+
Lacking Permissions
Self-Hosted
- MySQL/MariaDB + PostgreSQL
-
Lacking Permissions
- Self-Hosted + Azure
PostgreSQL
-
+
Lacking Permissions
Amazon Web Services (AWS)
- Aurora MySQL/MariaDB + RDS MySQL/MariaDB
Lacking Permissions
Amazon Web Services (AWS)
- RDS MySQL/MariaDB + RDS PostgreSQL
-
Lacking Permissions
@@ -3779,374 +3744,409 @@ exports[`render with no access 1`] = `
- Aurora PostgreSQL + RDS Proxy
-
- - - - +
Lacking Permissions
Server
- Debian 8+ + RHEL/CentOS 7+
-
Lacking Permissions
-
- Server -
+
- Ubuntu 14.04+ + Snowflake (Preview)
-
- - +
Lacking Permissions
-
- Application + Server +
+
+ Ubuntu 14.04+
@@ -4182,7 +4182,7 @@ exports[`render with partial access 1`] = ` width: 600px; } -.c13 { +.c12 { box-sizing: border-box; } @@ -4203,27 +4203,27 @@ exports[`render with partial access 1`] = ` margin-bottom: 32px; } -.c14 { +.c13 { overflow: hidden; text-overflow: ellipsis; - font-size: 12px; + font-weight: 600; margin: 0px; - color: rgba(255,255,255,0.72); } -.c15 { +.c14 { overflow: hidden; text-overflow: ellipsis; - font-weight: 600; + font-size: 12px; margin: 0px; - color: #FFFFFF; + color: rgba(255,255,255,0.72); } -.c16 { +.c18 { overflow: hidden; text-overflow: ellipsis; font-weight: 600; margin: 0px; + color: #FFFFFF; } .c19 { @@ -4234,7 +4234,7 @@ exports[`render with partial access 1`] = ` margin-top: 40px; } -.c7 { +.c17 { color: #009EFF; font-weight: normal; background: none; @@ -4251,14 +4251,14 @@ exports[`render with partial access 1`] = ` margin-left: 8px; } -.c12 { +.c11 { display: block; outline: none; width: 23.9px; height: 24px; } -.c10 { +.c9 { box-sizing: border-box; padding-left: 8px; padding-right: 8px; @@ -4266,7 +4266,7 @@ exports[`render with partial access 1`] = ` align-items: center; } -.c11 { +.c10 { box-sizing: border-box; margin-right: 16px; width: 24px; @@ -4274,7 +4274,7 @@ exports[`render with partial access 1`] = ` justify-content: center; } -.c9 { +.c16 { box-sizing: border-box; border-top-right-radius: 4px; border-bottom-left-radius: 4px; @@ -4293,7 +4293,7 @@ exports[`render with partial access 1`] = ` row-gap: 15px; } -.c8 { +.c7 { display: flex; position: relative; align-items: center; @@ -4304,14 +4304,14 @@ exports[`render with partial access 1`] = ` color: #FFFFFF; cursor: pointer; height: 48px; - opacity: 0.45; + opacity: 1; } -.c8:hover { +.c7:hover { background: rgba(255,255,255,0.13); } -.c17 { +.c15 { display: flex; position: relative; align-items: center; @@ -4322,14 +4322,14 @@ exports[`render with partial access 1`] = ` color: #FFFFFF; cursor: pointer; height: 48px; - opacity: 1; + opacity: 0.45; } -.c17:hover { +.c15:hover { background: rgba(255,255,255,0.13); } -.c18 { +.c8 { position: absolute; background: #9F85FF; color: #000000; @@ -4401,575 +4401,527 @@ exports[`render with partial access 1`] = `
-
- Lacking Permissions + Guided
+
- Database -
-
- Dynamic Registration + Application
-
- +
- Lacking Permissions + Guided
+
- Database -
-
- High Availability + Kubernetes
-
- +
- Lacking Permissions + Guided
- Amazon Web Services (AWS) + Windows Desktop
- RDS Proxy + Active Directory
-
- +
- Lacking Permissions + Guided
-
- Snowflake (Preview) + Server +
+
+ Amazon Linux 2/2023
-
- +
- Lacking Permissions + Guided
- Self-Hosted + Server
- Redis Cluster + Debian 8+
-
- +
- Lacking Permissions + Guided
- Self-Hosted + Server
- Redis + macOS
-
- +
- Lacking Permissions + Guided
- Self-Hosted + Server
- MongoDB + RHEL/CentOS 7+
-
- +
- Lacking Permissions + Guided
- Self-Hosted + Server
- Elasticsearch + Ubuntu 14.04+
-
- +
Lacking Permissions
- Self-Hosted + Amazon Web Services (AWS)
- CockroachDB + Aurora MySQL/MariaDB
-
- +
Lacking Permissions
-
+ Azure +
+
- MongoDB Atlas + Cache for Redis
Lacking Permissions
- Google Cloud Provider (GCP) + Self-Hosted
- Cloud SQL PostgreSQL + Cassandra & ScyllaDB
Lacking Permissions
Cloud SQL MySQL @@ -4988,356 +4940,356 @@ exports[`render with partial access 1`] = `
Lacking Permissions
- Microsoft + Google Cloud Provider (GCP)
- SQL Server (Preview) + Cloud SQL PostgreSQL
Lacking Permissions
- Azure + Self-Hosted
- SQL Server (Preview) + CockroachDB
Lacking Permissions
- Azure + Database
- MySQL + Dynamic Registration
Lacking Permissions
- Azure + Amazon Web Services (AWS)
- PostgreSQL + DynamoDB
Lacking Permissions
- Azure + Amazon Web Services (AWS)
- Cache for Redis + ElastiCache & MemoryDB
Lacking Permissions
- Amazon Web Services (AWS) + Self-Hosted
- Redshift Serverless + Elasticsearch
Lacking Permissions
- Amazon Web Services (AWS) + Database
- Redshift PostgreSQL + High Availability
Lacking Permissions
Keyspaces (Apache Cassandra) @@ -5356,165 +5308,163 @@ exports[`render with partial access 1`] = `
Lacking Permissions
- Amazon Web Services (AWS) + Self-Hosted
- ElastiCache & MemoryDB + MongoDB
Lacking Permissions
+
- Amazon Web Services (AWS) -
-
- DynamoDB + MongoDB Atlas
-
Lacking Permissions
- Self-Hosted + Azure
- MySQL/MariaDB + MySQL
-
+
Lacking Permissions
- PostgreSQL + MySQL/MariaDB
Lacking Permissions
- Amazon Web Services (AWS) + Self-Hosted
- Aurora MySQL/MariaDB + PostgreSQL
-
Lacking Permissions
- Amazon Web Services (AWS) + Azure
- RDS MySQL/MariaDB + PostgreSQL
-
+
Lacking Permissions
- Aurora PostgreSQL + RDS MySQL/MariaDB
{ + const testCases: { + name: string; + param: MarketingParams; + expected: ClusterResource[]; + }[] = [ + { + name: 'database matches RESOURCE_DATABASES & k8s matches RESOURCE_KUBERNETES', + param: { + campaign: 'foodatabasebar', + source: 'k8ski', + medium: '', + intent: '', + }, + expected: [ + ClusterResource.RESOURCE_DATABASES, + ClusterResource.RESOURCE_KUBERNETES, + ], + }, + { + name: 'app matches RESOURCE_WEB_APPLICATIONS', + param: { + campaign: '', + source: 'baz', + medium: '', + intent: 'fooappbar', + }, + expected: [ClusterResource.RESOURCE_WEB_APPLICATIONS], + }, + { + name: 'windows matches RESOURCE_WINDOWS_DESKTOPS', + param: { + campaign: 'foowindowsbar', + source: '', + medium: '', + intent: 'aws', + }, + expected: [ClusterResource.RESOURCE_WINDOWS_DESKTOPS], + }, + { + name: 'desktop matches RESOURCE_WINDOWS_DESKTOPS', + param: { + campaign: '', + source: '', + medium: 'foodesktopbar', + intent: 'shoo', + }, + expected: [ClusterResource.RESOURCE_WINDOWS_DESKTOPS], + }, + { + name: 'ssh matches RESOURCE_SERVER_SSH', + param: { + campaign: '', + source: 'foosshbar', + medium: 'bar', + intent: '', + }, + expected: [ClusterResource.RESOURCE_SERVER_SSH], + }, + { + name: 'server matches RESOURCE_SERVER_SSH', + param: { + campaign: 'fooserverbar', + source: '', + medium: '', + intent: 'ser', + }, + expected: [ClusterResource.RESOURCE_SERVER_SSH], + }, + { + name: 'kube matches RESOURCE_KUBERNETES and windows matches RESOURCE_WINDOWS_DESKTOPS', + param: { + campaign: 'fookubebar', + source: '', + medium: 'windows', + intent: '', + }, + expected: [ + ClusterResource.RESOURCE_KUBERNETES, + ClusterResource.RESOURCE_WINDOWS_DESKTOPS, + ], + }, + { + name: 'kubernetes matches RESOURCE_KUBERNETES', + param: { + campaign: 'kubernetes', + source: '', + medium: '', + intent: '', + }, + expected: [ClusterResource.RESOURCE_KUBERNETES], + }, + { + name: 'kube matches RESOURCE_KUBERNETES', + param: { + campaign: '', + source: 'kube', + medium: '', + intent: '', + }, + expected: [ClusterResource.RESOURCE_KUBERNETES], + }, + { + name: 'k8s matches RESOURCE_KUBERNETES and ssh matches RESOURCE_SERVER_SSH', + param: { + campaign: '', + source: '', + medium: 'fook8sbar', + intent: 'ssh', + }, + expected: [ + ClusterResource.RESOURCE_KUBERNETES, + ClusterResource.RESOURCE_SERVER_SSH, + ], + }, + { + name: 'aws does not match', + param: { + campaign: 'fooaws', + source: 'aws', + medium: 'awshoo', + intent: 'no aws', + }, + expected: [], + }, + { + name: 'does not match when empty', + param: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + expected: [], + }, + ]; + test.each(testCases)('$name', testCase => { + expect(getMarketingTermMatches(testCase.param)).toEqual(testCase.expected); + }); +}); diff --git a/web/packages/teleport/src/Discover/SelectResource/getMarketingTermMatches.ts b/web/packages/teleport/src/Discover/SelectResource/getMarketingTermMatches.ts new file mode 100644 index 0000000000000..4f51d3075c05d --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/getMarketingTermMatches.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ClusterResource, + MarketingParams, +} from 'teleport/services/userPreferences/types'; + +/** + * Returns a list of resource kinds that match provided marketing parameters. + * + * @param marketingParams - MarketingParams from cluster user preferences which are set at signup + * @returns an array of ClusterResource associated with the marketing params for resource discoverability + * + */ +export const getMarketingTermMatches = ( + marketingParams: MarketingParams +): ClusterResource[] => { + const params = []; + if (marketingParams) { + marketingParams.campaign && params.push(marketingParams.campaign); + marketingParams.medium && params.push(marketingParams.medium); + marketingParams.source && params.push(marketingParams.source); + marketingParams.intent && params.push(marketingParams.intent); + } + if (params.length === 0) { + return []; + } + + const matches = new Set(); + params.forEach(p => { + Object.values(TermMatch).forEach(m => { + const clusterResource = matchTerm(m); + if (p.includes(m) && clusterResource) { + matches.add(clusterResource); + } + }); + }); + + return Array.from(matches); +}; + +export enum TermMatch { + App = 'app', + Database = 'database', + Desktop = 'desktop', + K8s = 'k8s', + Kube = 'kube', + Kubernetes = 'kubernetes', + Server = 'server', + SSH = 'ssh', + Windows = 'windows', + AWS = 'aws', +} + +const matchTerm = (m: string): ClusterResource => { + switch (m) { + case TermMatch.App: + return ClusterResource.RESOURCE_WEB_APPLICATIONS; + case TermMatch.Database: + return ClusterResource.RESOURCE_DATABASES; + case TermMatch.Kube: + case TermMatch.Kubernetes: + case TermMatch.K8s: + return ClusterResource.RESOURCE_KUBERNETES; + case TermMatch.SSH: + case TermMatch.Server: + return ClusterResource.RESOURCE_SERVER_SSH; + case TermMatch.Desktop: + case TermMatch.Windows: + return ClusterResource.RESOURCE_WINDOWS_DESKTOPS; + // currently we have no resource kind nor cluster resource defined for AWS + // in the future, we can search the resources based on this term. + case TermMatch.AWS: + default: + return null; + } +}; diff --git a/web/packages/teleport/src/Discover/SelectResource/types.ts b/web/packages/teleport/src/Discover/SelectResource/types.ts index 54e658fa79c4e..25fb96f848649 100644 --- a/web/packages/teleport/src/Discover/SelectResource/types.ts +++ b/web/packages/teleport/src/Discover/SelectResource/types.ts @@ -16,6 +16,8 @@ import { Platform } from 'design/theme/utils'; +import { ClusterResource } from 'teleport/services/userPreferences/types'; + import { ResourceKind } from '../Shared/ResourceKind'; import type { DiscoverEventResource } from 'teleport/services/userEvent'; @@ -88,3 +90,8 @@ export enum SearchResource { SERVER = 'server', UNIFIED_RESOURCE = 'unified_resource', } + +export type PrioritizedResources = { + preferredResources: ClusterResource[]; + hasPreferredResources: boolean; +}; diff --git a/web/packages/teleport/src/services/localStorage/localStorage.ts b/web/packages/teleport/src/services/localStorage/localStorage.ts index 11a1559c9c311..4e35b88607777 100644 --- a/web/packages/teleport/src/services/localStorage/localStorage.ts +++ b/web/packages/teleport/src/services/localStorage/localStorage.ts @@ -162,7 +162,15 @@ const storage = { return userPreferences.onboard; } - return { preferredResources: [] }; + return { + preferredResources: [], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; }, // DELETE IN 15 (ryan) diff --git a/web/packages/teleport/src/services/localStorage/types.ts b/web/packages/teleport/src/services/localStorage/types.ts index c1e356b6174e7..1a6ef238a18bb 100644 --- a/web/packages/teleport/src/services/localStorage/types.ts +++ b/web/packages/teleport/src/services/localStorage/types.ts @@ -34,7 +34,7 @@ export const KeysEnum = { export type SurveyRequest = { companyName: string; employeeCount: string; - resources: Array; + resourcesList: Array; role: string; team: string; }; @@ -42,4 +42,13 @@ export type SurveyRequest = { // LocalStorageSurvey is the SurveyRequest type defined in Enterprise export type LocalStorageSurvey = SurveyRequest & { clusterResources: Array; + marketingParams: LocalStorageMarketingParams; +}; + +// LocalStorageMarketingParams is the MarketingParams type defined in Enterprise +export type LocalStorageMarketingParams = { + campaign: string; + source: string; + medium: string; + intent: string; }; diff --git a/web/packages/teleport/src/services/userPreferences/types.ts b/web/packages/teleport/src/services/userPreferences/types.ts index 25b87ffb8ed1f..9777e12fbcaa6 100644 --- a/web/packages/teleport/src/services/userPreferences/types.ts +++ b/web/packages/teleport/src/services/userPreferences/types.ts @@ -32,8 +32,16 @@ export enum ClusterResource { RESOURCE_WEB_APPLICATIONS = 5, } +export type MarketingParams = { + campaign: string; + source: string; + medium: string; + intent: string; +}; + export type OnboardUserPreferences = { preferredResources: ClusterResource[]; + marketingParams: MarketingParams; }; export interface UserPreferences { diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts index d027d12d24ec7..bbc6adab50703 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts @@ -17,8 +17,8 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; -import { ThemePreference } from 'teleport/services/userPreferences/types'; import { ViewMode } from 'teleport/Assist/types'; +import { ThemePreference } from 'teleport/services/userPreferences/types'; import type { GetUserPreferencesResponse, @@ -46,6 +46,12 @@ export function makeDefaultUserPreferences(): UserPreferences { }, onboard: { preferredResources: [], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, }, }; }