diff --git a/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go new file mode 100644 index 0000000000000..e41df80ac9a86 --- /dev/null +++ b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go @@ -0,0 +1,209 @@ +// Copyright 2025 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.1 +// protoc (unknown) +// source: teleport/userpreferences/v1/discover_resource_preferences.proto + +package userpreferencesv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DiscoverGuide defines preferences related to discover guides. +type DiscoverGuide struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // pinned is a list of ids of pinned guides. + Pinned []string `protobuf:"bytes,1,rep,name=pinned,proto3" json:"pinned,omitempty"` +} + +func (x *DiscoverGuide) Reset() { + *x = DiscoverGuide{} + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoverGuide) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoverGuide) ProtoMessage() {} + +func (x *DiscoverGuide) ProtoReflect() protoreflect.Message { + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoverGuide.ProtoReflect.Descriptor instead. +func (*DiscoverGuide) Descriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{0} +} + +func (x *DiscoverGuide) GetPinned() []string { + if x != nil { + return x.Pinned + } + return nil +} + +// DiscoverResourcePreferences holds preferences related to discovering resource. +type DiscoverResourcePreferences struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // discover_guide defines preferences related to discover guides. + DiscoverGuide *DiscoverGuide `protobuf:"bytes,2,opt,name=discover_guide,json=discoverGuide,proto3" json:"discover_guide,omitempty"` +} + +func (x *DiscoverResourcePreferences) Reset() { + *x = DiscoverResourcePreferences{} + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoverResourcePreferences) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoverResourcePreferences) ProtoMessage() {} + +func (x *DiscoverResourcePreferences) ProtoReflect() protoreflect.Message { + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoverResourcePreferences.ProtoReflect.Descriptor instead. +func (*DiscoverResourcePreferences) Descriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{1} +} + +func (x *DiscoverResourcePreferences) GetDiscoverGuide() *DiscoverGuide { + if x != nil { + return x.DiscoverGuide + } + return nil +} + +var File_teleport_userpreferences_v1_discover_resource_preferences_proto protoreflect.FileDescriptor + +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc = []byte{ + 0x0a, 0x3f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 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, 0x27, + 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x22, 0x85, 0x01, 0x0a, 0x1b, 0x44, 0x69, 0x73, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x51, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x63, 0x6f, + 0x76, 0x65, 0x72, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x2a, 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, 0x44, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x52, 0x0d, 0x64, 0x69, 0x73, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, + 0x52, 0x0d, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x73, 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 ( + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescOnce sync.Once + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData = file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc +) + +func file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP() []byte { + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescOnce.Do(func() { + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData = protoimpl.X.CompressGZIP(file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData) + }) + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData +} + +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes = []any{ + (*DiscoverGuide)(nil), // 0: teleport.userpreferences.v1.DiscoverGuide + (*DiscoverResourcePreferences)(nil), // 1: teleport.userpreferences.v1.DiscoverResourcePreferences +} +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs = []int32{ + 0, // 0: teleport.userpreferences.v1.DiscoverResourcePreferences.discover_guide:type_name -> teleport.userpreferences.v1.DiscoverGuide + 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 +} + +func init() { file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() } +func file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() { + if File_teleport_userpreferences_v1_discover_resource_preferences_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes, + DependencyIndexes: file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs, + MessageInfos: file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes, + }.Build() + File_teleport_userpreferences_v1_discover_resource_preferences_proto = out.File + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc = nil + file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes = nil + file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs = nil +} diff --git a/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go b/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go index 62eaf64e6e225..50a98abc28119 100644 --- a/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go +++ b/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go @@ -53,6 +53,8 @@ type UserPreferences struct { AccessGraph *AccessGraphUserPreferences `protobuf:"bytes,6,opt,name=access_graph,json=accessGraph,proto3" json:"access_graph,omitempty"` // side_nav_drawer_mode is the sidenav drawer behavior preference in the frontend. SideNavDrawerMode SideNavDrawerMode `protobuf:"varint,7,opt,name=side_nav_drawer_mode,json=sideNavDrawerMode,proto3,enum=teleport.userpreferences.v1.SideNavDrawerMode" json:"side_nav_drawer_mode,omitempty"` + // discover_resource_preferences are user preferences saved for the discover resource web UI. + DiscoverResourcePreferences *DiscoverResourcePreferences `protobuf:"bytes,8,opt,name=discover_resource_preferences,json=discoverResourcePreferences,proto3" json:"discover_resource_preferences,omitempty"` } func (x *UserPreferences) Reset() { @@ -127,6 +129,13 @@ func (x *UserPreferences) GetSideNavDrawerMode() SideNavDrawerMode { return SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_UNSPECIFIED } +func (x *UserPreferences) GetDiscoverResourcePreferences() *DiscoverResourcePreferences { + if x != nil { + return x.DiscoverResourcePreferences + } + return nil +} + // GetUserPreferencesRequest is a request to get the user preferences. type GetUserPreferencesRequest struct { state protoimpl.MessageState @@ -274,97 +283,109 @@ var file_teleport_userpreferences_v1_userpreferences_proto_rawDesc = []byte{ 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x3f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 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, 0x1a, - 0x35, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, 0x64, - 0x65, 0x6e, 0x61, 0x76, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x27, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, - 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, - 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, - 0x3e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x6e, 0x69, - 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0xc6, 0x04, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x22, 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, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x4d, 0x0a, - 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, - 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, 0x4f, 0x6e, 0x62, - 0x6f, 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x52, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x12, 0x64, 0x0a, 0x13, - 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x74, 0x65, 0x6c, 0x65, + 0x31, 0x2f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, + 0x75, 0x73, 0x65, 0x72, 0x70, 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, + 0x1a, 0x35, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, + 0x64, 0x65, 0x6e, 0x61, 0x76, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x27, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x3e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x6e, + 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0xc4, 0x05, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x22, 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, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x4d, + 0x0a, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x33, 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, 0x4f, 0x6e, + 0x62, 0x6f, 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x52, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x12, 0x64, 0x0a, + 0x13, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 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, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, + 0x12, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x12, 0x79, 0x0a, 0x1c, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x12, - 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x12, 0x79, 0x0a, 0x1c, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, + 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x52, 0x1a, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x5a, + 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x67, 0x72, 0x61, 0x70, 0x68, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x61, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x12, 0x5f, 0x0a, 0x14, 0x73, 0x69, + 0x64, 0x65, 0x5f, 0x6e, 0x61, 0x76, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x65, 0x72, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 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, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x73, 0x52, 0x1a, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x5a, 0x0a, - 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x67, 0x72, 0x61, 0x70, 0x68, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x55, 0x73, 0x65, - 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x61, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x12, 0x5f, 0x0a, 0x14, 0x73, 0x69, 0x64, - 0x65, 0x5f, 0x6e, 0x61, 0x76, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x65, 0x72, 0x5f, 0x6d, 0x6f, 0x64, - 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, + 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, 0x44, 0x72, + 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x11, 0x73, 0x69, 0x64, 0x65, 0x4e, 0x61, + 0x76, 0x44, 0x72, 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x7c, 0x0a, 0x1d, 0x64, + 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x38, 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, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x1b, 0x64, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, + 0x06, 0x61, 0x73, 0x73, 0x69, 0x73, 0x74, 0x22, 0x2b, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, + 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x6c, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x18, 0x01, 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, 0x53, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, 0x44, 0x72, 0x61, - 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x11, 0x73, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, - 0x44, 0x72, 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, - 0x52, 0x06, 0x61, 0x73, 0x73, 0x69, 0x73, 0x74, 0x22, 0x2b, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, 0x08, 0x75, 0x73, 0x65, - 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x6c, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, - 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x01, 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, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x22, 0x7e, 0x0a, 0x1c, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, - 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x01, 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, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x32, 0x8c, 0x02, 0x0a, 0x16, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x85, - 0x01, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, - 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x36, 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, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x37, 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, 0x47, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, - 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, - 0x39, 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, 0x55, 0x70, - 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 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, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x22, 0x7e, 0x0a, 0x1c, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, + 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x18, 0x01, 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, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x32, 0x8c, 0x02, 0x0a, 0x16, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x85, 0x01, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x12, 0x36, 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, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x37, 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, 0x47, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, + 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x39, + 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, 0x55, 0x70, 0x73, + 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 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 ( @@ -391,7 +412,8 @@ var file_teleport_userpreferences_v1_userpreferences_proto_goTypes = []any{ (*UnifiedResourcePreferences)(nil), // 7: teleport.userpreferences.v1.UnifiedResourcePreferences (*AccessGraphUserPreferences)(nil), // 8: teleport.userpreferences.v1.AccessGraphUserPreferences (SideNavDrawerMode)(0), // 9: teleport.userpreferences.v1.SideNavDrawerMode - (*emptypb.Empty)(nil), // 10: google.protobuf.Empty + (*DiscoverResourcePreferences)(nil), // 10: teleport.userpreferences.v1.DiscoverResourcePreferences + (*emptypb.Empty)(nil), // 11: google.protobuf.Empty } var file_teleport_userpreferences_v1_userpreferences_proto_depIdxs = []int32{ 4, // 0: teleport.userpreferences.v1.UserPreferences.theme:type_name -> teleport.userpreferences.v1.Theme @@ -400,17 +422,18 @@ var file_teleport_userpreferences_v1_userpreferences_proto_depIdxs = []int32{ 7, // 3: teleport.userpreferences.v1.UserPreferences.unified_resource_preferences:type_name -> teleport.userpreferences.v1.UnifiedResourcePreferences 8, // 4: teleport.userpreferences.v1.UserPreferences.access_graph:type_name -> teleport.userpreferences.v1.AccessGraphUserPreferences 9, // 5: teleport.userpreferences.v1.UserPreferences.side_nav_drawer_mode:type_name -> teleport.userpreferences.v1.SideNavDrawerMode - 0, // 6: teleport.userpreferences.v1.GetUserPreferencesResponse.preferences:type_name -> teleport.userpreferences.v1.UserPreferences - 0, // 7: teleport.userpreferences.v1.UpsertUserPreferencesRequest.preferences:type_name -> teleport.userpreferences.v1.UserPreferences - 1, // 8: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:input_type -> teleport.userpreferences.v1.GetUserPreferencesRequest - 3, // 9: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:input_type -> teleport.userpreferences.v1.UpsertUserPreferencesRequest - 2, // 10: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:output_type -> teleport.userpreferences.v1.GetUserPreferencesResponse - 10, // 11: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:output_type -> google.protobuf.Empty - 10, // [10:12] is the sub-list for method output_type - 8, // [8:10] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 10, // 6: teleport.userpreferences.v1.UserPreferences.discover_resource_preferences:type_name -> teleport.userpreferences.v1.DiscoverResourcePreferences + 0, // 7: teleport.userpreferences.v1.GetUserPreferencesResponse.preferences:type_name -> teleport.userpreferences.v1.UserPreferences + 0, // 8: teleport.userpreferences.v1.UpsertUserPreferencesRequest.preferences:type_name -> teleport.userpreferences.v1.UserPreferences + 1, // 9: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:input_type -> teleport.userpreferences.v1.GetUserPreferencesRequest + 3, // 10: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:input_type -> teleport.userpreferences.v1.UpsertUserPreferencesRequest + 2, // 11: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:output_type -> teleport.userpreferences.v1.GetUserPreferencesResponse + 11, // 12: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:output_type -> google.protobuf.Empty + 11, // [11:13] is the sub-list for method output_type + 9, // [9:11] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_teleport_userpreferences_v1_userpreferences_proto_init() } @@ -420,6 +443,7 @@ func file_teleport_userpreferences_v1_userpreferences_proto_init() { } file_teleport_userpreferences_v1_access_graph_proto_init() file_teleport_userpreferences_v1_cluster_preferences_proto_init() + file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() file_teleport_userpreferences_v1_onboard_proto_init() file_teleport_userpreferences_v1_sidenav_preferences_proto_init() file_teleport_userpreferences_v1_theme_proto_init() diff --git a/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto new file mode 100644 index 0000000000000..97431bcea1307 --- /dev/null +++ b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto @@ -0,0 +1,33 @@ +// Copyright 2025 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. + +syntax = "proto3"; + +package teleport.userpreferences.v1; + +option go_package = "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1;userpreferencesv1"; + +// DiscoverGuide defines preferences related to discover guides. +message DiscoverGuide { + // pinned is a list of ids of pinned guides. + repeated string pinned = 1; +} + +// DiscoverResourcePreferences holds preferences related to discovering resource. +message DiscoverResourcePreferences { + reserved 1; + reserved "pinned_guides"; + // discover_guide defines preferences related to discover guides. + DiscoverGuide discover_guide = 2; +} diff --git a/api/proto/teleport/userpreferences/v1/userpreferences.proto b/api/proto/teleport/userpreferences/v1/userpreferences.proto index 25d7fe4884379..b2926d07f1098 100644 --- a/api/proto/teleport/userpreferences/v1/userpreferences.proto +++ b/api/proto/teleport/userpreferences/v1/userpreferences.proto @@ -19,6 +19,7 @@ package teleport.userpreferences.v1; import "google/protobuf/empty.proto"; import "teleport/userpreferences/v1/access_graph.proto"; import "teleport/userpreferences/v1/cluster_preferences.proto"; +import "teleport/userpreferences/v1/discover_resource_preferences.proto"; import "teleport/userpreferences/v1/onboard.proto"; import "teleport/userpreferences/v1/sidenav_preferences.proto"; import "teleport/userpreferences/v1/theme.proto"; @@ -43,6 +44,8 @@ message UserPreferences { AccessGraphUserPreferences access_graph = 6; // side_nav_drawer_mode is the sidenav drawer behavior preference in the frontend. SideNavDrawerMode side_nav_drawer_mode = 7; + // discover_resource_preferences are user preferences saved for the discover resource web UI. + DiscoverResourcePreferences discover_resource_preferences = 8; } // GetUserPreferencesRequest is a request to get the user preferences. diff --git a/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts new file mode 100644 index 0000000000000..763ba7ee433a1 --- /dev/null +++ b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts @@ -0,0 +1,148 @@ +/* eslint-disable */ +// @generated by protobuf-ts 2.9.3 with parameter eslint_disable,add_pb_suffix,server_grpc1,ts_nocheck +// @generated from protobuf file "teleport/userpreferences/v1/discover_resource_preferences.proto" (package "teleport.userpreferences.v1", syntax proto3) +// tslint:disable +// @ts-nocheck +// +// Copyright 2025 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 type { BinaryWriteOptions } from "@protobuf-ts/runtime"; +import type { IBinaryWriter } from "@protobuf-ts/runtime"; +import { WireType } from "@protobuf-ts/runtime"; +import type { BinaryReadOptions } from "@protobuf-ts/runtime"; +import type { IBinaryReader } from "@protobuf-ts/runtime"; +import { UnknownFieldHandler } from "@protobuf-ts/runtime"; +import type { PartialMessage } from "@protobuf-ts/runtime"; +import { reflectionMergePartial } from "@protobuf-ts/runtime"; +import { MessageType } from "@protobuf-ts/runtime"; +/** + * DiscoverGuide defines preferences related to discover guides. + * + * @generated from protobuf message teleport.userpreferences.v1.DiscoverGuide + */ +export interface DiscoverGuide { + /** + * pinned is a list of ids of pinned guides. + * + * @generated from protobuf field: repeated string pinned = 1; + */ + pinned: string[]; +} +/** + * DiscoverResourcePreferences holds preferences related to discovering resource. + * + * @generated from protobuf message teleport.userpreferences.v1.DiscoverResourcePreferences + */ +export interface DiscoverResourcePreferences { + /** + * discover_guide defines preferences related to discover guides. + * + * @generated from protobuf field: teleport.userpreferences.v1.DiscoverGuide discover_guide = 2; + */ + discoverGuide?: DiscoverGuide; +} +// @generated message type with reflection information, may provide speed optimized methods +class DiscoverGuide$Type extends MessageType { + constructor() { + super("teleport.userpreferences.v1.DiscoverGuide", [ + { no: 1, name: "pinned", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): DiscoverGuide { + const message = globalThis.Object.create((this.messagePrototype!)); + message.pinned = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DiscoverGuide): DiscoverGuide { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated string pinned */ 1: + message.pinned.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DiscoverGuide, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string pinned = 1; */ + for (let i = 0; i < message.pinned.length; i++) + writer.tag(1, WireType.LengthDelimited).string(message.pinned[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.userpreferences.v1.DiscoverGuide + */ +export const DiscoverGuide = new DiscoverGuide$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class DiscoverResourcePreferences$Type extends MessageType { + constructor() { + super("teleport.userpreferences.v1.DiscoverResourcePreferences", [ + { no: 2, name: "discover_guide", kind: "message", T: () => DiscoverGuide } + ]); + } + create(value?: PartialMessage): DiscoverResourcePreferences { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DiscoverResourcePreferences): DiscoverResourcePreferences { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* teleport.userpreferences.v1.DiscoverGuide discover_guide */ 2: + message.discoverGuide = DiscoverGuide.internalBinaryRead(reader, reader.uint32(), options, message.discoverGuide); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DiscoverResourcePreferences, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* teleport.userpreferences.v1.DiscoverGuide discover_guide = 2; */ + if (message.discoverGuide) + DiscoverGuide.internalBinaryWrite(message.discoverGuide, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.userpreferences.v1.DiscoverResourcePreferences + */ +export const DiscoverResourcePreferences = new DiscoverResourcePreferences$Type(); diff --git a/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts b/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts index 2c768f07491f2..c350516bf9e86 100644 --- a/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts +++ b/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts @@ -29,6 +29,7 @@ import { UnknownFieldHandler } from "@protobuf-ts/runtime"; import type { PartialMessage } from "@protobuf-ts/runtime"; import { reflectionMergePartial } from "@protobuf-ts/runtime"; import { MessageType } from "@protobuf-ts/runtime"; +import { DiscoverResourcePreferences } from "./discover_resource_preferences_pb"; import { SideNavDrawerMode } from "./sidenav_preferences_pb"; import { AccessGraphUserPreferences } from "./access_graph_pb"; import { UnifiedResourcePreferences } from "./unified_resource_preferences_pb"; @@ -77,6 +78,12 @@ export interface UserPreferences { * @generated from protobuf field: teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode = 7; */ sideNavDrawerMode: SideNavDrawerMode; + /** + * discover_resource_preferences are user preferences saved for the discover resource web UI. + * + * @generated from protobuf field: teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences = 8; + */ + discoverResourcePreferences?: DiscoverResourcePreferences; } /** * GetUserPreferencesRequest is a request to get the user preferences. @@ -120,7 +127,8 @@ class UserPreferences$Type extends MessageType { { no: 4, name: "cluster_preferences", kind: "message", T: () => ClusterUserPreferences }, { no: 5, name: "unified_resource_preferences", kind: "message", T: () => UnifiedResourcePreferences }, { no: 6, name: "access_graph", kind: "message", T: () => AccessGraphUserPreferences }, - { no: 7, name: "side_nav_drawer_mode", kind: "enum", T: () => ["teleport.userpreferences.v1.SideNavDrawerMode", SideNavDrawerMode, "SIDE_NAV_DRAWER_MODE_"] } + { no: 7, name: "side_nav_drawer_mode", kind: "enum", T: () => ["teleport.userpreferences.v1.SideNavDrawerMode", SideNavDrawerMode, "SIDE_NAV_DRAWER_MODE_"] }, + { no: 8, name: "discover_resource_preferences", kind: "message", T: () => DiscoverResourcePreferences } ]); } create(value?: PartialMessage): UserPreferences { @@ -154,6 +162,9 @@ class UserPreferences$Type extends MessageType { case /* teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode */ 7: message.sideNavDrawerMode = reader.int32(); break; + case /* teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences */ 8: + message.discoverResourcePreferences = DiscoverResourcePreferences.internalBinaryRead(reader, reader.uint32(), options, message.discoverResourcePreferences); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -184,6 +195,9 @@ class UserPreferences$Type extends MessageType { /* teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode = 7; */ if (message.sideNavDrawerMode !== 0) writer.tag(7, WireType.Varint).int32(message.sideNavDrawerMode); + /* teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences = 8; */ + if (message.discoverResourcePreferences) + DiscoverResourcePreferences.internalBinaryWrite(message.discoverResourcePreferences, writer.tag(8, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/lib/services/local/userpreferences_test.go b/lib/services/local/userpreferences_test.go index 8abe30a1ec6ee..16bcf947148e0 100644 --- a/lib/services/local/userpreferences_test.go +++ b/lib/services/local/userpreferences_test.go @@ -219,6 +219,30 @@ func TestUserPreferencesCRUD(t *testing.T) { SideNavDrawerMode: userpreferencesv1.SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_STICKY, }, }, + { + name: "update the discover resource guide preference only", + req: &userpreferencesv1.UpsertUserPreferencesRequest{ + Preferences: &userpreferencesv1.UserPreferences{ + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + DiscoverGuide: &userpreferencesv1.DiscoverGuide{ + Pinned: []string{"guide-1", "guide-2"}, + }, + }, + }, + }, + expected: &userpreferencesv1.UserPreferences{ + Onboard: defaultPref.Onboard, + Theme: defaultPref.Theme, + UnifiedResourcePreferences: defaultPref.UnifiedResourcePreferences, + ClusterPreferences: defaultPref.ClusterPreferences, + SideNavDrawerMode: defaultPref.SideNavDrawerMode, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + DiscoverGuide: &userpreferencesv1.DiscoverGuide{ + Pinned: []string{"guide-1", "guide-2"}, + }, + }, + }, + }, { name: "update all the settings at once", req: &userpreferencesv1.UpsertUserPreferencesRequest{ @@ -245,6 +269,11 @@ func TestUserPreferencesCRUD(t *testing.T) { ResourceIds: []string{"node1", "node2"}, }, }, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + DiscoverGuide: &userpreferencesv1.DiscoverGuide{ + Pinned: []string{"guide-3", "guide-4"}, + }, + }, }, }, expected: &userpreferencesv1.UserPreferences{ @@ -269,6 +298,11 @@ func TestUserPreferencesCRUD(t *testing.T) { ResourceIds: []string{"node1", "node2"}, }, }, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + DiscoverGuide: &userpreferencesv1.DiscoverGuide{ + Pinned: []string{"guide-3", "guide-4"}, + }, + }, SideNavDrawerMode: userpreferencesv1.SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_STICKY, }, }, diff --git a/lib/web/userpreferences.go b/lib/web/userpreferences.go index e6476022eccc5..622cb67b73e72 100644 --- a/lib/web/userpreferences.go +++ b/lib/web/userpreferences.go @@ -64,15 +64,28 @@ type AccessGraphPreferencesResponse struct { HasBeenRedirected bool `json:"hasBeenRedirected"` } +// DiscoverGuidePreferences defines preferences related to discover guides. +type DiscoverGuidePreferences struct { + // PinnedGuides is a list of ids of pinned guides. + Pinned []string `json:"pinned"` +} + +// DiscoverResourcePreferencesResponse is the JSON response for discover resource preference +// as part of the user preference request. +type DiscoverResourcePreferencesResponse struct { + DiscoverGuide *DiscoverGuidePreferences `json:"discoverGuide"` +} + // UserPreferencesResponse is the JSON response for the user preferences. type UserPreferencesResponse struct { - Assist AssistUserPreferencesResponse `json:"assist"` - Theme userpreferencesv1.Theme `json:"theme"` - UnifiedResourcePreferences UnifiedResourcePreferencesResponse `json:"unifiedResourcePreferences"` - Onboard OnboardUserPreferencesResponse `json:"onboard"` - ClusterPreferences ClusterUserPreferencesResponse `json:"clusterPreferences,omitempty"` - AccessGraph AccessGraphPreferencesResponse `json:"accessGraph,omitempty"` - SideNavDrawerMode userpreferencesv1.SideNavDrawerMode `json:"sideNavDrawerMode"` + Assist AssistUserPreferencesResponse `json:"assist"` + Theme userpreferencesv1.Theme `json:"theme"` + UnifiedResourcePreferences UnifiedResourcePreferencesResponse `json:"unifiedResourcePreferences"` + Onboard OnboardUserPreferencesResponse `json:"onboard"` + ClusterPreferences ClusterUserPreferencesResponse `json:"clusterPreferences,omitempty"` + DiscoverResourcePreferences DiscoverResourcePreferencesResponse `json:"discoverResourcePreferences"` + AccessGraph AccessGraphPreferencesResponse `json:"accessGraph,omitempty"` + SideNavDrawerMode userpreferencesv1.SideNavDrawerMode `json:"sideNavDrawerMode"` } func (h *Handler) getUserClusterPreferences(_ http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { @@ -127,6 +140,12 @@ func (h *Handler) getUserPreferences(_ http.ResponseWriter, r *http.Request, _ h } func makePreferenceRequest(req UserPreferencesResponse) *userpreferencesv1.UpsertUserPreferencesRequest { + var discoverGuide *userpreferencesv1.DiscoverGuide + if req.DiscoverResourcePreferences.DiscoverGuide != nil { + discoverGuide = &userpreferencesv1.DiscoverGuide{ + Pinned: req.DiscoverResourcePreferences.DiscoverGuide.Pinned, + } + } return &userpreferencesv1.UpsertUserPreferencesRequest{ Preferences: &userpreferencesv1.UserPreferences{ Theme: req.Theme, @@ -154,6 +173,9 @@ func makePreferenceRequest(req UserPreferencesResponse) *userpreferencesv1.Upser HasBeenRedirected: req.AccessGraph.HasBeenRedirected, }, SideNavDrawerMode: req.SideNavDrawerMode, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + DiscoverGuide: discoverGuide, + }, }, } } @@ -182,12 +204,13 @@ func (h *Handler) updateUserPreferences(_ http.ResponseWriter, r *http.Request, // userPreferencesResponse creates a JSON response for the user preferences. func userPreferencesResponse(resp *userpreferencesv1.UserPreferences) *UserPreferencesResponse { jsonResp := &UserPreferencesResponse{ - Theme: resp.Theme, - Onboard: onboardUserPreferencesResponse(resp.Onboard), - ClusterPreferences: clusterPreferencesResponse(resp.ClusterPreferences), - UnifiedResourcePreferences: unifiedResourcePreferencesResponse(resp.UnifiedResourcePreferences), - AccessGraph: accessGraphPreferencesResponse(resp.AccessGraph), - SideNavDrawerMode: resp.SideNavDrawerMode, + Theme: resp.Theme, + Onboard: onboardUserPreferencesResponse(resp.Onboard), + ClusterPreferences: clusterPreferencesResponse(resp.ClusterPreferences), + UnifiedResourcePreferences: unifiedResourcePreferencesResponse(resp.UnifiedResourcePreferences), + AccessGraph: accessGraphPreferencesResponse(resp.AccessGraph), + SideNavDrawerMode: resp.SideNavDrawerMode, + DiscoverResourcePreferences: discoverResourcePreferenceResponse(resp.DiscoverResourcePreferences), } return jsonResp @@ -243,3 +266,16 @@ func accessGraphPreferencesResponse(resp *userpreferencesv1.AccessGraphUserPrefe HasBeenRedirected: resp.HasBeenRedirected, } } + +// discoverResourcePreferenceResponse creates a JSON response for the discover resource preferences. +func discoverResourcePreferenceResponse(resp *userpreferencesv1.DiscoverResourcePreferences) DiscoverResourcePreferencesResponse { + if resp == nil || resp.GetDiscoverGuide() == nil { + return DiscoverResourcePreferencesResponse{} + } + + return DiscoverResourcePreferencesResponse{ + DiscoverGuide: &DiscoverGuidePreferences{ + Pinned: resp.GetDiscoverGuide().GetPinned(), + }, + } +} diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx index 422e3501848c2..4708f19cc9969 100644 --- a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx @@ -35,6 +35,8 @@ export default function InputSearch({ setSearchValue, children, bigInputSize = false, + autoFocus = false, + placeholder = 'Search...', }: Props) { function submitSearch(e: FormEvent) { e.preventDefault(); // prevent form default @@ -50,10 +52,11 @@ export default function InputSearch({
{children} @@ -68,6 +71,8 @@ type Props = { setSearchValue: (searchValue: string) => void; children?: JSX.Element; bigInputSize?: boolean; + autoFocus?: boolean; + placeholder?: string; }; const ChildWrapper = styled.div` diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx index 01ffdc258fcb2..e73ae78263457 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx @@ -473,7 +473,7 @@ export function UnifiedResources(props: UnifiedResourcesProps) { bg="levels.sunken" details={updatePinnedResourcesAttempt.statusText} > - Could not update pinned resources: + Could not update pinned resources )} {unifiedResourcePreferencesAttempt?.status === 'error' && ( diff --git a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx index ecaadd42b21f8..a6ea24f67556a 100644 --- a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx +++ b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx @@ -23,8 +23,10 @@ import { PushPin, PushPinFilled } from 'design/Icon'; import { HoverTooltip } from 'design/Tooltip'; import { PinningSupport } from '../types'; -import { PINNING_NOT_SUPPORTED_MESSAGE } from '../UnifiedResources'; +// TODO(kimlisa): move this out of the UnifiedResources directory, +// since it is also used outside of UnifiedResources +// (eg: Discover/SelectResource.tsx) export function PinButton({ pinned, pinningSupport, @@ -55,10 +57,18 @@ export function PinButton({ return ( { + // This ButtonIcon can be used within another element that also has a + // onClick handler (stops propagating click event) or within an + // anchor element (prevents browser default to go the link). + e.stopPropagation(); + e.preventDefault(); + setPinned(); + }} className={className} css={` visibility: ${shouldShowButton ? 'visible' : 'hidden'}; @@ -83,7 +93,7 @@ function getTipContent( ): string { switch (pinningSupport) { case PinningSupport.NotSupported: - return PINNING_NOT_SUPPORTED_MESSAGE; + return 'To enable pinning support, upgrade to 17.3 or newer.'; case PinningSupport.Supported: return pinned ? 'Unpin' : 'Pin'; default: diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx index 12abddcc32b6f..c7dd146c15c6c 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx @@ -144,6 +144,7 @@ const Provider = ({ children }) => { const updatePreferences = () => Promise.resolve(); const getClusterPinnedResources = () => Promise.resolve([]); const updateClusterPinnedResources = () => Promise.resolve(); + const updateDiscoverResourcePreferences = () => Promise.resolve(); return ( @@ -153,6 +154,7 @@ const Provider = ({ children }) => { updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {children} diff --git a/web/packages/teleport/src/Discover/Discover.test.tsx b/web/packages/teleport/src/Discover/Discover.test.tsx index d484dbf0456e2..289dbaf9172e0 100644 --- a/web/packages/teleport/src/Discover/Discover.test.tsx +++ b/web/packages/teleport/src/Discover/Discover.test.tsx @@ -25,12 +25,10 @@ import cfg from 'teleport/config'; import { Discover, DiscoverComponent } from 'teleport/Discover/Discover'; import { ResourceViewConfig } from 'teleport/Discover/flow'; import { + APPLICATIONS, DATABASES, DATABASES_UNGUIDED, DATABASES_UNGUIDED_DOC, -} from 'teleport/Discover/SelectResource/databases'; -import { - APPLICATIONS, KUBERNETES, SERVERS, } from 'teleport/Discover/SelectResource/resources'; @@ -44,6 +42,7 @@ import { makeTestUserContext } from 'teleport/User/testHelpers/makeTestUserConte import { mockUserContextProviderWith } from 'teleport/User/testHelpers/mockUserContextWith'; import { ResourceKind } from './Shared'; +import { getGuideTileId } from './testUtils'; import { DiscoverUpdateProps, useDiscover } from './useDiscover'; beforeEach(() => { @@ -90,18 +89,24 @@ test('displays all resources by default', () => { expect( screen - .getAllByTestId(ResourceKind.Server) - .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + .getAllByTestId(getGuideTileId({ kind: ResourceKind.Server })) + .concat( + screen.getAllByTestId( + getGuideTileId({ kind: ResourceKind.ConnectMyComputer }) + ) + ) ).toHaveLength(SERVERS.length); - expect(screen.getAllByTestId(ResourceKind.Database)).toHaveLength( + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength( DATABASES.length + DATABASES_UNGUIDED.length + DATABASES_UNGUIDED_DOC.length ); - expect(screen.getAllByTestId(ResourceKind.Application)).toHaveLength( - APPLICATIONS.length - ); - expect(screen.getAllByTestId(ResourceKind.Kubernetes)).toHaveLength( - KUBERNETES.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).toHaveLength(APPLICATIONS.length); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) + ).toHaveLength(KUBERNETES.length); }); test('location state applies filter/search', () => { @@ -111,11 +116,17 @@ test('location state applies filter/search', () => { }); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Kubernetes)).not.toBeInTheDocument(); }); describe('location state', () => { @@ -124,79 +135,109 @@ describe('location state', () => { expect( screen - .getAllByTestId(ResourceKind.Server) - .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + .getAllByTestId(getGuideTileId({ kind: ResourceKind.Server })) + .concat( + screen.getAllByTestId( + getGuideTileId({ kind: ResourceKind.ConnectMyComputer }) + ) + ) ).toHaveLength(SERVERS.length); // we assert three databases for servers because the naming convention includes "server" - expect(screen.queryAllByTestId(ResourceKind.Database)).toHaveLength(4); + expect( + screen.queryAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength(4); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays desktops when the location state is desktop', () => { create({ initialEntry: 'desktop' }); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays apps when the location state is application', () => { create({ initialEntry: 'application' }); - expect(screen.getAllByTestId(ResourceKind.Application)).toHaveLength( - APPLICATIONS.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).toHaveLength(APPLICATIONS.length); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays databases when the location state is database', () => { create({ initialEntry: 'database' }); - expect(screen.getAllByTestId(ResourceKind.Database)).toHaveLength( + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength( DATABASES.length + DATABASES_UNGUIDED.length + DATABASES_UNGUIDED_DOC.length ); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays kube resources when the location state is kubernetes', () => { create({ initialEntry: 'kubernetes' }); - expect(screen.getAllByTestId(ResourceKind.Kubernetes)).toHaveLength( - KUBERNETES.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) + ).toHaveLength(KUBERNETES.length); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); expect( screen.queryByTestId(ResourceKind.Application) ).not.toBeInTheDocument(); diff --git a/web/packages/teleport/src/Discover/Fixtures/databases.tsx b/web/packages/teleport/src/Discover/Fixtures/databases.tsx index d39910f2efc30..a699e37eb0520 100644 --- a/web/packages/teleport/src/Discover/Fixtures/databases.tsx +++ b/web/packages/teleport/src/Discover/Fixtures/databases.tsx @@ -30,7 +30,7 @@ import { IntegrationStatusCode, } from 'teleport/services/integrations'; -import { DATABASES } from '../SelectResource/databases'; +import { DATABASES } from '../SelectResource/resources'; import { ResourceKind } from '../Shared'; import { TeleportProvider } from './fixtures'; diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx index 517792f6090d5..4fa1af39d53fb 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx @@ -96,6 +96,7 @@ const Provider = ({ const updatePreferences = () => Promise.resolve(); const getClusterPinnedResources = () => Promise.resolve([]); const updateClusterPinnedResources = () => Promise.resolve(); + const updateDiscoverResourcePreferences = () => Promise.resolve(); const preferences: UserPreferences = makeDefaultUserPreferences(); preferences.onboard.preferredResources = resources; @@ -109,6 +110,7 @@ const Provider = ({ updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {children} diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx index 496f7481a6e62..2e27f87ca912d 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx @@ -16,10 +16,11 @@ * along with this program. If not, see . */ +import { within } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { Platform, UserAgent } from 'design/platform'; -import { render, screen, waitFor } from 'design/utils/testing'; +import { render, screen, userEvent, waitFor } from 'design/utils/testing'; import { OnboardUserPreferences, Resource, @@ -32,17 +33,36 @@ import { noAccess, } from 'teleport/mocks/contexts'; import { OnboardDiscover } from 'teleport/services/user'; +import * as service from 'teleport/services/userPreferences/userPreferences'; import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/userPreferences'; import * as userUserContext from 'teleport/User/UserContext'; +import { UserContextProvider } from 'teleport/User/UserContext'; import { ResourceKind } from '../Shared'; import { resourceKindToPreferredResource } from '../Shared/ResourceKind'; +import { getGuideTileId } from '../testUtils'; +import { SelectResourceSpec } from './resources'; +import { SelectResource } from './SelectResource'; import { - filterResources, - SelectResource, - sortResources, -} from './SelectResource'; -import { ResourceSpec } from './types'; + a_DatabaseAws, + c_ApplicationGcp, + d_Saml, + e_KubernetesSelfHosted_unguided, + f_Server, + g_Application, + h_Server, + i_Desktop, + j_Kubernetes, + k_Database, + kindBasedList, + l_DesktopAzure, + l_Saml, + makeResourceSpec, + NoAccessList, +} from './testUtils'; +import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters'; +import { defaultPins } from './utils/pins'; +import { sortResourcesByPreferences } from './utils/sort'; const setUp = () => { jest @@ -50,22 +70,6 @@ const setUp = () => { .mockReturnValue(UserAgent.macOS); }; -const makeResourceSpec = ( - overrides: Partial = {} -): ResourceSpec => { - return Object.assign( - { - name: '', - kind: ResourceKind.Application, - icon: '', - event: null, - keywords: [], - hasAccess: true, - }, - overrides - ); -}; - /** * If the user has resources, Connect My Computer is not prioritized when sorting resources. */ @@ -85,9 +89,13 @@ const onboardDiscoverNoResources: OnboardDiscover = { hasVisited: false, }; -test('sortResources without preferred resources, sorts resources alphabetically with guided resources first', () => { +beforeEach(() => { + jest.restoreAllMocks(); +}); + +test('sortResourcesByPreferences without preferred resources, sorts resources alphabetically with guided resources first', () => { setUp(); - const mockIn: ResourceSpec[] = [ + const mockIn: SelectResourceSpec[] = [ // unguided makeResourceSpec({ name: 'jenkins', unguidedLink: 'test.com' }), makeResourceSpec({ name: 'grafana', unguidedLink: 'test.com' }), @@ -99,7 +107,7 @@ test('sortResources without preferred resources, sorts resources alphabetically makeResourceSpec({ name: 'costco' }), ]; - const actual = sortResources( + const actual = sortResourcesByPreferences( mockIn, makeDefaultUserPreferences(), onboardDiscoverWithResources @@ -118,121 +126,6 @@ test('sortResources without preferred resources, sorts resources alphabetically ]); }); -const t_Application_NoAccess = makeResourceSpec({ - name: 'tango', - kind: ResourceKind.Application, - hasAccess: false, -}); -const u_Database_NoAccess = makeResourceSpec({ - name: 'uniform', - kind: ResourceKind.Database, - hasAccess: false, -}); -const v_Desktop_NoAccess = makeResourceSpec({ - name: 'victor', - kind: ResourceKind.Desktop, - hasAccess: false, -}); -const w_Kubernetes_NoAccess = makeResourceSpec({ - name: 'whiskey', - kind: ResourceKind.Kubernetes, - hasAccess: false, -}); -const x_Server_NoAccess = makeResourceSpec({ - name: 'xray', - kind: ResourceKind.Server, - hasAccess: false, -}); -const y_Saml_NoAccess = makeResourceSpec({ - name: 'yankee', - kind: ResourceKind.SamlApplication, - hasAccess: false, -}); -const z_Discovery_NoAccess = makeResourceSpec({ - name: 'zulu', - kind: ResourceKind.Discovery, - hasAccess: false, -}); - -const NoAccessList: ResourceSpec[] = [ - t_Application_NoAccess, - u_Database_NoAccess, - v_Desktop_NoAccess, - w_Kubernetes_NoAccess, - x_Server_NoAccess, - y_Saml_NoAccess, - z_Discovery_NoAccess, -]; - -const c_Application = makeResourceSpec({ - name: 'charlie', - kind: ResourceKind.Application, -}); -const a_Database = makeResourceSpec({ - name: 'alpha', - kind: ResourceKind.Database, -}); -const l_Desktop = makeResourceSpec({ - name: 'linux', - kind: ResourceKind.Desktop, -}); -const e_Kubernetes_unguided = makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', -}); -const f_Server = makeResourceSpec({ - name: 'foxtrot', - kind: ResourceKind.Server, -}); -const d_Saml = makeResourceSpec({ - name: 'delta', - kind: ResourceKind.SamlApplication, -}); -const g_Application = makeResourceSpec({ - name: 'golf', - kind: ResourceKind.Application, -}); -const k_Database = makeResourceSpec({ - name: 'kilo', - kind: ResourceKind.Database, -}); -const i_Desktop = makeResourceSpec({ - name: 'india', - kind: ResourceKind.Desktop, -}); -const j_Kubernetes = makeResourceSpec({ - name: 'juliette', - kind: ResourceKind.Kubernetes, -}); -const h_Server = makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }); -const l_Saml = makeResourceSpec({ - name: 'lima', - kind: ResourceKind.SamlApplication, -}); - -const kindBasedList: ResourceSpec[] = [ - c_Application, - a_Database, - t_Application_NoAccess, - l_Desktop, - e_Kubernetes_unguided, - u_Database_NoAccess, - f_Server, - w_Kubernetes_NoAccess, - d_Saml, - v_Desktop_NoAccess, - g_Application, - x_Server_NoAccess, - k_Database, - i_Desktop, - z_Discovery_NoAccess, - j_Kubernetes, - h_Server, - y_Saml_NoAccess, - l_Saml, -]; - describe('preferred resources', () => { beforeEach(() => { setUp(); @@ -241,7 +134,7 @@ describe('preferred resources', () => { const testCases: { name: string; preferred: Resource[]; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'preferred server/ssh', @@ -251,16 +144,16 @@ describe('preferred resources', () => { f_Server, h_Server, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, g_Application, i_Desktop, j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -270,10 +163,10 @@ describe('preferred resources', () => { preferred: [Resource.DATABASES], expected: [ // preferred first - a_Database, + a_DatabaseAws, k_Database, // alpha; guided before unguided - c_Application, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -281,8 +174,8 @@ describe('preferred resources', () => { i_Desktop, j_Kubernetes, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -293,10 +186,10 @@ describe('preferred resources', () => { expected: [ // preferred first i_Desktop, - l_Desktop, + l_DesktopAzure, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -304,7 +197,7 @@ describe('preferred resources', () => { j_Kubernetes, k_Database, l_Saml, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -314,10 +207,10 @@ describe('preferred resources', () => { preferred: [Resource.WEB_APPLICATIONS], expected: [ // preferred first - c_Application, + c_ApplicationGcp, g_Application, // alpha; guided before unguided - a_Database, + a_DatabaseAws, d_Saml, f_Server, h_Server, @@ -325,8 +218,8 @@ describe('preferred resources', () => { j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -337,10 +230,10 @@ describe('preferred resources', () => { expected: [ // preferred first; guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -348,7 +241,7 @@ describe('preferred resources', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -358,7 +251,7 @@ describe('preferred resources', () => { test.each(testCases)('$name', testCase => { const preferences = makeDefaultUserPreferences(); preferences.onboard.preferredResources = testCase.preferred; - const actual = sortResources( + const actual = sortResourcesByPreferences( kindBasedList, preferences, onboardDiscoverWithResources @@ -376,7 +269,7 @@ describe('marketing params', () => { const testCases: { name: string; preferred: OnboardUserPreferences; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'marketing params instead of preferred resources', @@ -392,10 +285,10 @@ describe('marketing params', () => { expected: [ // marketing params first; no preferred priority, guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -403,7 +296,7 @@ describe('marketing params', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -424,16 +317,16 @@ describe('marketing params', () => { f_Server, h_Server, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, g_Application, i_Desktop, j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -451,10 +344,10 @@ describe('marketing params', () => { }, expected: [ // preferred first - a_Database, + a_DatabaseAws, k_Database, // alpha; guided before unguided - c_Application, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -462,8 +355,8 @@ describe('marketing params', () => { i_Desktop, j_Kubernetes, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -482,10 +375,10 @@ describe('marketing params', () => { expected: [ // preferred first i_Desktop, - l_Desktop, + l_DesktopAzure, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -493,7 +386,7 @@ describe('marketing params', () => { j_Kubernetes, k_Database, l_Saml, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -511,10 +404,10 @@ describe('marketing params', () => { }, expected: [ // preferred first - c_Application, + c_ApplicationGcp, g_Application, // alpha; guided before unguided - a_Database, + a_DatabaseAws, d_Saml, f_Server, h_Server, @@ -522,8 +415,8 @@ describe('marketing params', () => { j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -542,10 +435,10 @@ describe('marketing params', () => { expected: [ // preferred first; guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -553,7 +446,7 @@ describe('marketing params', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -563,7 +456,7 @@ describe('marketing params', () => { test.each(testCases)('$name', testCase => { const preferences = makeDefaultUserPreferences(); preferences.onboard = testCase.preferred; - const actual = sortResources( + const actual = sortResourcesByPreferences( kindBasedList, preferences, onboardDiscoverWithResources @@ -573,7 +466,7 @@ describe('marketing params', () => { }); }); -const osBasedList: ResourceSpec[] = [ +const osBasedList: SelectResourceSpec[] = [ makeResourceSpec({ name: 'Aaaa' }), makeResourceSpec({ name: 'no-linux-1', @@ -601,7 +494,7 @@ describe('os sorted resources', () => { const testCases: { name: string; userAgent: UserAgent; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'running mac', @@ -707,7 +600,7 @@ describe('os sorted resources', () => { test.each(testCases)('$name', testCase => { OS.mockReturnValue(testCase.userAgent); - const actual = sortResources( + const actual = sortResourcesByPreferences( osBasedList, makeDefaultUserPreferences(), onboardDiscoverWithResources @@ -716,7 +609,7 @@ describe('os sorted resources', () => { }); test('does not prioritize os if the user does not have access', () => { - const mockIn: ResourceSpec[] = [ + const mockIn: SelectResourceSpec[] = [ makeResourceSpec({ name: 'macOs', platform: Platform.macOS, @@ -726,7 +619,7 @@ describe('os sorted resources', () => { ]; OS.mockReturnValue(UserAgent.macOS); - const actual = sortResources( + const actual = sortResourcesByPreferences( mockIn, makeDefaultUserPreferences(), onboardDiscoverWithResources @@ -741,7 +634,7 @@ describe('os sorted resources', () => { ]); }); - const oneOfEachList: ResourceSpec[] = [ + const oneOfEachList: SelectResourceSpec[] = [ makeResourceSpec({ name: 'no access but super matches', hasAccess: false, @@ -773,7 +666,7 @@ describe('os sorted resources', () => { }, }; - const actual = sortResources( + const actual = sortResourcesByPreferences( oneOfEachList, preferences, onboardDiscoverWithResources @@ -853,7 +746,7 @@ describe('sorting Connect My Computer', () => { it('puts the Connect My Computer resource as the first resource if the user has no preferences', () => { OS.mockReturnValue(UserAgent.macOS); - const actual = sortResources( + const actual = sortResourcesByPreferences( oneOfEachList, makeDefaultUserPreferences(), onboardDiscoverNoResources @@ -892,7 +785,7 @@ describe('sorting Connect My Computer', () => { }, }; - const actual = sortResources( + const actual = sortResourcesByPreferences( oneOfEachList, preferences, onboardDiscoverNoResources @@ -935,7 +828,7 @@ describe('sorting Connect My Computer', () => { platform: Platform.Linux, }); - const actual = sortResources( + const actual = sortResourcesByPreferences( [ unguidedA, guidedServerForMatchingPlatformB, @@ -988,7 +881,7 @@ describe('sorting Connect My Computer', () => { }, }; - const actual = sortResources( + const actual = sortResourcesByPreferences( [ unguidedA, guidedServerForMatchingPlatformB, @@ -1014,7 +907,7 @@ describe('sorting Connect My Computer', () => { it('puts the Connect My Computer resource as the last guided resource if the user has resources', () => { OS.mockReturnValue(UserAgent.macOS); - const actual = sortResources( + const actual = sortResourcesByPreferences( oneOfEachList, makeDefaultUserPreferences(), onboardDiscoverWithResources @@ -1053,7 +946,7 @@ describe('sorting Connect My Computer', () => { }, }; - const actual = sortResources( + const actual = sortResourcesByPreferences( oneOfEachList, preferences, onboardDiscoverWithResources @@ -1099,7 +992,7 @@ describe('sorting Connect My Computer', () => { }, }; - const actual = sortResources( + const actual = sortResourcesByPreferences( [...oneOfEachList, databaseForAnotherPlatform], preferences, onboardDiscoverNoResources @@ -1132,6 +1025,7 @@ test('displays an info banner if lacking "all" permissions to add resources', as updatePreferences: () => null, updateClusterPinnedResources: () => null, getClusterPinnedResources: () => null, + updateDiscoverResourcePreferences: () => null, }); const ctx = createTeleportContext(); @@ -1152,12 +1046,59 @@ test('displays an info banner if lacking "all" permissions to add resources', as }); }); +test('add and remove pin, and rendering of default pins', async () => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValue(UserAgent.macOS); + + const prefs = makeDefaultUserPreferences(); + jest.spyOn(service, 'getUserPreferences').mockResolvedValue(prefs); + jest.spyOn(service, 'updateUserPreferences').mockResolvedValue(prefs); + + render( + + + + {}} /> + + + + ); + + await screen.findAllByTestId(/large-tile-/); + + // Default pins on initial render with no preferences set. + let pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length); + + // Add pin. + let snowflakeGuide = screen.getByTestId( + getGuideTileId({ kind: ResourceKind.Database, title: 'snowflake' }) + ); + await userEvent.click(within(snowflakeGuide).getByTestId(/pin-button/i)); + pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length + 1); + + // Remove pin. + snowflakeGuide = screen.getByTestId( + getGuideTileId({ + kind: ResourceKind.Database, + title: 'snowflake', + size: 'large', + }) + ); + await userEvent.click(within(snowflakeGuide).getByTestId(/pin-button/i)); + pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length); +}); + test('does not display erorr banner if user has "some" permissions to add', async () => { jest.spyOn(userUserContext, 'useUser').mockReturnValue({ preferences: makeDefaultUserPreferences(), updatePreferences: () => null, updateClusterPinnedResources: () => null, getClusterPinnedResources: () => null, + updateDiscoverResourcePreferences: () => null, }); const ctx = createTeleportContext(); @@ -1176,7 +1117,7 @@ test('does not display erorr banner if user has "some" permissions to add', asyn ).not.toBeInTheDocument(); }); -describe('filterResources', () => { +describe('filterBySupportedPlatformsAndAuthTypes', () => { it('filters out resources based on supportedPlatforms', () => { const winAndLinux = makeResourceSpec({ name: 'Filtered out with many supported platforms', @@ -1195,12 +1136,11 @@ describe('filterResources', () => { supportedPlatforms: [Platform.macOS], }); - const result = filterResources(Platform.macOS, 'local', [ - winAndLinux, - win, - macosAndLinux, - macos, - ]); + const result = filterBySupportedPlatformsAndAuthTypes( + Platform.macOS, + 'local', + [winAndLinux, win, macosAndLinux, macos] + ); expect(result).toContain(macosAndLinux); expect(result).toContain(macos); @@ -1209,24 +1149,28 @@ describe('filterResources', () => { }); it('does not filter out resources with supportedPlatforms and supportedAuthTypes that are missing or empty', () => { - const result = filterResources(Platform.macOS, 'local', [ - makeResourceSpec({ - name: 'Empty supportedPlatforms', - supportedPlatforms: [], - }), - makeResourceSpec({ - name: 'Missing supportedPlatforms', - supportedPlatforms: undefined, - }), - makeResourceSpec({ - name: 'Empty supportedAuthTypes', - supportedAuthTypes: [], - }), - makeResourceSpec({ - name: 'Missing supportedAuthTypes', - supportedAuthTypes: undefined, - }), - ]); + const result = filterBySupportedPlatformsAndAuthTypes( + Platform.macOS, + 'local', + [ + makeResourceSpec({ + name: 'Empty supportedPlatforms', + supportedPlatforms: [], + }), + makeResourceSpec({ + name: 'Missing supportedPlatforms', + supportedPlatforms: undefined, + }), + makeResourceSpec({ + name: 'Empty supportedAuthTypes', + supportedAuthTypes: [], + }), + makeResourceSpec({ + name: 'Missing supportedAuthTypes', + supportedAuthTypes: undefined, + }), + ] + ); expect(result).toHaveLength(4); }); @@ -1249,12 +1193,11 @@ describe('filterResources', () => { supportedAuthTypes: ['local'], }); - const result = filterResources(Platform.macOS, 'local', [ - ssoAndPasswordless, - sso, - localAndPasswordless, - local, - ]); + const result = filterBySupportedPlatformsAndAuthTypes( + Platform.macOS, + 'local', + [ssoAndPasswordless, sso, localAndPasswordless, local] + ); expect(result).toContain(localAndPasswordless); expect(result).toContain(local); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 34b0d848933b8..afd9cd6facaf8 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -16,52 +16,50 @@ * along with this program. If not, see . */ -import { - useEffect, - useMemo, - useState, - type ComponentPropsWithoutRef, -} from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router'; import styled from 'styled-components'; -import { Alert, Box, Flex, Link, P3, Text } from 'design'; -import * as Icons from 'design/Icon'; -import { NewTab } from 'design/Icon'; -import { getPlatform, Platform } from 'design/platform'; -import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb'; -import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; +import { Alert, Box, Flex, H3, Link, P3, Text } from 'design'; +import { Danger } from 'design/Alert'; +import InputSearch from 'design/DataTable/InputSearch'; +import { Magnifier } from 'design/Icon'; +import { getPlatform } from 'design/platform'; +import { MultiselectMenu } from 'shared/components/Controls/MultiselectMenu'; +import { PinningSupport } from 'shared/components/UnifiedResources'; +import { useAsync } from 'shared/hooks/useAsync'; import AddApp from 'teleport/Apps/AddApp'; -import { FeatureHeader, FeatureHeaderTitle } from 'teleport/components/Layout'; -import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge'; +import { FeatureHeaderTitle } from 'teleport/components/Layout'; import cfg from 'teleport/config'; -import { - BASE_RESOURCES, - getResourcePretitle, -} from 'teleport/Discover/SelectResource/resources'; -import { - HeaderSubtitle, - PermissionsErrorMessage, - ResourceKind, -} from 'teleport/Discover/Shared'; -import { resourceKindToPreferredResource } from 'teleport/Discover/Shared/ResourceKind'; +import { BASE_RESOURCES } from 'teleport/Discover/SelectResource/resources/resources'; import { storageService } from 'teleport/services/storageService'; -import { Acl, AuthType, OnboardDiscover } from 'teleport/services/user'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; import { useUser } from 'teleport/User/UserContext'; import useTeleport from 'teleport/useTeleport'; -import { getMarketingTermMatches } from './getMarketingTermMatches'; -import { DiscoverIcon } from './icons'; -import { SAML_APPLICATIONS } from './resourcesE'; +import { TextIcon } from '../Shared'; +import { SelectResourceSpec } from './resources'; +import { SAML_APPLICATIONS } from './resources/resourcesE'; +import { Tile } from './Tile'; +import { SearchResource } from './types'; +import { addHasAccessField } from './utils/checkAccess'; +import { + filterBySupportedPlatformsAndAuthTypes, + filterResources, + Filters, + hostingPlatformOptions, + resourceTypeOptions, +} from './utils/filters'; import { - PrioritizedResources, - SearchResource, - type ResourceSpec, -} from './types'; + DiscoverResourcePreference, + getDefaultPins, + getPins, +} from './utils/pins'; +import { sortResourcesByKind, sortResourcesByPreferences } from './utils/sort'; interface SelectResourceProps { - onSelect: (resource: ResourceSpec) => void; + onSelect: (resource: SelectResourceSpec) => void; } type UrlLocationState = { @@ -71,7 +69,7 @@ type UrlLocationState = { function getDefaultResources( includeEnterpriseResources: boolean -): ResourceSpec[] { +): SelectResourceSpec[] { const RESOURCES = includeEnterpriseResources ? [...BASE_RESOURCES, ...SAML_APPLICATIONS] : BASE_RESOURCES; @@ -82,30 +80,58 @@ export function SelectResource({ onSelect }: SelectResourceProps) { const ctx = useTeleport(); const location = useLocation(); const history = useHistory(); - const { preferences } = useUser(); + const { preferences, updateDiscoverResourcePreferences } = useUser(); + + const [filters, setFilters] = useState({ + resourceTypes: [], + hostingPlatforms: [], + }); + const [updateDiscoverPreferenceAttempt, updateDiscoverPreference] = useAsync( + async (newPref: DiscoverResourcePreference) => { + await updateDiscoverResourcePreferences(newPref); + } + ); const [search, setSearch] = useState(''); const { acl, authType } = ctx.storeUser.state; const platform = getPlatform(); - const defaultResources: ResourceSpec[] = useMemo( - () => - sortResources( - // Apply access check to each resource. - addHasAccessField( - acl, - filterResources( - platform, - authType, - getDefaultResources(cfg.isEnterprise) - ) - ), - preferences, - storageService.getOnboardDiscover() - ), - [acl, authType, platform, preferences] - ); + + /** + * defaultResources does initial processing of all resource guides that will + * be used as base for dynamic filtering and determining default pins: + * - sets the "hasAccess" field (checks user perms) + * - sets the "pinned" field (checks user discover resource preference) + * - filters out guides that are not supported by users + * platform and auth settings (eg: "Connect My Computer" guide + * has limited support for platforms and auth settings) + * - sorts resources where preferred resources are at top of the list + * (certain cloud editions renders a questionaire for new users asking + * for their interest in resources) + */ + const defaultResources: SelectResourceSpec[] = useMemo(() => { + const withHasAccessFieldResources = addHasAccessField( + acl, + filterBySupportedPlatformsAndAuthTypes( + platform, + authType, + getDefaultResources(cfg.isEnterprise) + ) + ); + + return sortResourcesByPreferences( + withHasAccessFieldResources, + preferences, + storageService.getOnboardDiscover() + ); + }, [acl, authType, platform, preferences]); + const [resources, setResources] = useState(defaultResources); + const filteredResources = useMemo( + () => filterResources(resources, filters), + [filters, resources] + ); + // a user must be able to create tokens AND have access to create at least one // type of resource in order to be considered eligible to "add resources" const canAddResources = @@ -113,8 +139,14 @@ export function SelectResource({ onSelect }: SelectResourceProps) { const [showApp, setShowApp] = useState(false); - function onSearch(s: string, customList?: ResourceSpec[]) { + function onSearch(s: string, customList?: SelectResourceSpec[]) { const list = customList || defaultResources; + if (s == '') { + history.replace({ state: {} }); // Clear any loc state. + setResources(list); + setSearch(s); + return; + } const search = s.split(' ').map(s => s.toLowerCase()); const found = list.filter(r => search.every(s => r.keywords.some(k => k.toLowerCase().includes(s))) @@ -124,11 +156,6 @@ export function SelectResource({ onSelect }: SelectResourceProps) { setSearch(s); } - function onClearSearch() { - history.replace({ state: {} }); // Clear any loc state. - onSearch(''); - } - useEffect(() => { // A user can come to this screen by clicking on // a `add ` button. @@ -163,6 +190,47 @@ export function SelectResource({ onSelect }: SelectResourceProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + async function updatePinnedGuides(guideId: DiscoverGuideId) { + const { discoverResourcePreferences } = preferences; + + let previousPins = discoverResourcePreferences?.discoverGuide?.pinned || []; + + if (!discoverResourcePreferences?.discoverGuide) { + previousPins = getDefaultPins(defaultResources); + } + + // Toggles pins. + let latestPins: string[]; + if (previousPins.includes(guideId)) { + latestPins = previousPins.filter(p => p !== guideId); + } else { + latestPins = [...previousPins, guideId]; + } + + const newPreferences: DiscoverResourcePreference = { + discoverResourcePreferences: { + discoverGuide: { pinned: latestPins }, + }, + }; + + updateDiscoverPreference(newPreferences); + } + + // TODO(kimlisa): DELETE IN 19.0 - only remove the check for "NotSupported". + let pinningSupport = preferences.discoverResourcePreferences + ? PinningSupport.Supported + : PinningSupport.NotSupported; + + if (updateDiscoverPreferenceAttempt.status === 'processing') { + pinningSupport = PinningSupport.Disabled; + } + + const pins = getPins(preferences); + let pinnedGuides: SelectResourceSpec[] = []; + if (pins.length > 0) { + pinnedGuides = filteredResources.filter(r => pins.includes(r.id)); + } + return ( {!canAddResources && ( @@ -171,122 +239,88 @@ export function SelectResource({ onSelect }: SelectResourceProps) { for additional permissions. )} - - Select Resource To Add - - - Teleport can integrate into most, if not all, of your infrastructure. - Search for what resource you want to add. - - - - + Could not update pinned resources + + )} + + Enroll a New Resource + + Teleport can integrate with most, if not all, of your infrastructure. + Search below for resources you want to add. + + + + + onSearch(e.target.value)} - max={100} /> - - {search && } - - {resources && resources.length > 0 && ( + + + + setFilters({ ...filters, resourceTypes })} + selected={filters.resourceTypes || []} + label="Resource Type" + tooltip="Filter by resource type" + /> + + setFilters({ ...filters, hostingPlatforms }) + } + selected={filters.hostingPlatforms || []} + label="Hosting Platform" + tooltip="Filter by hosting platform" + /> + + {!filteredResources.length && ( + + + No results found + + )} + {pinnedGuides.length > 0 && ( + +

Pinned

+ + {pinnedGuides.map(r => ( + + ))} + +
+ )} + {filteredResources.length > 0 && ( <> + {pinnedGuides.length > 0 &&

All Resources

} - {resources.map((r, index) => { - const title = r.name; - const pretitle = getResourcePretitle(r); - const select = () => { - if (!r.hasAccess) { - return; - } - - setShowApp(true); - onSelect(r); - }; - - let resourceCardProps: ComponentPropsWithoutRef< - 'button' | typeof Link - >; - - if (r.kind === ResourceKind.Application && r.isDialog) { - resourceCardProps = { - onClick: select, - onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(), - role: 'button', - }; - } else if (r.unguidedLink) { - resourceCardProps = { - as: Link, - href: r.hasAccess ? r.unguidedLink : null, - target: '_blank', - style: { textDecoration: 'none' }, - role: 'link', - }; - } else { - resourceCardProps = { - onClick: () => r.hasAccess && onSelect(r), - onKeyUp: (e: KeyboardEvent) => { - if (e.key === 'Enter' && r.hasAccess) { - onSelect(r); - } - }, - role: 'button', - }; - } - - // There can be three types of click behavior with the resource cards: - // 1) If the resource has no interactive UI flow ("unguided"), - // clicking on the card will take a user to our docs page - // on a new tab. - // 2) If the resource is guided, we start the "flow" by - // taking user to the next step. - // 3) If the resource is kind 'Application', it will render the legacy - // popup modal where it shows user to add app manually or automatically. - return ( - - {!r.unguidedLink && r.hasAccess && ( - Guided - )} - {!r.hasAccess && ( - } - /> - )} - - - - - - {pretitle && ( - - {pretitle} - - )} - {r.unguidedLink ? ( - - {title} - - ) : ( - {title} - )} - - - - {r.unguidedLink && r.hasAccess ? ( - - ) : null} - - ); - })} + {filteredResources.map(r => ( + + ))} - + Looking for something else?{' '} { - return ( - - props.theme.colors.error.main}; - `} - > - - - Clear search - - ); -}; - -function checkHasAccess(acl: Acl, resourceKind: ResourceKind) { - const basePerm = acl.tokens.create; - if (!basePerm) { - return false; - } - - switch (resourceKind) { - case ResourceKind.Application: - return acl.appServers.read && acl.appServers.list; - case ResourceKind.Database: - return acl.dbServers.read && acl.dbServers.list; - case ResourceKind.Desktop: - return acl.desktops.read && acl.desktops.list; - case ResourceKind.Kubernetes: - return acl.kubeServers.read && acl.kubeServers.list; - case ResourceKind.Server: - return acl.nodes.list; - case ResourceKind.SamlApplication: - return acl.samlIdpServiceProvider.create; - case ResourceKind.ConnectMyComputer: - // This is probably already true since without this permission the user wouldn't be able to - // add any other resource, but let's just leave it for completeness sake. - return acl.tokens.create; - default: - return false; - } -} - -function sortResourcesByKind( - resourceKind: SearchResource, - resources: ResourceSpec[] -) { - let sorted: ResourceSpec[] = []; - switch (resourceKind) { - case SearchResource.SERVER: - sorted = [ - ...resources.filter(r => r.kind === ResourceKind.Server), - ...resources.filter(r => r.kind !== ResourceKind.Server), - ]; - break; - case SearchResource.APPLICATION: - sorted = [ - ...resources.filter(r => r.kind === ResourceKind.Application), - ...resources.filter(r => r.kind !== ResourceKind.Application), - ]; - break; - case SearchResource.DATABASE: - sorted = [ - ...resources.filter(r => r.kind === ResourceKind.Database), - ...resources.filter(r => r.kind !== ResourceKind.Database), - ]; - break; - case SearchResource.DESKTOP: - sorted = [ - ...resources.filter(r => r.kind === ResourceKind.Desktop), - ...resources.filter(r => r.kind !== ResourceKind.Desktop), - ]; - break; - case SearchResource.KUBERNETES: - sorted = [ - ...resources.filter(r => r.kind === ResourceKind.Kubernetes), - ...resources.filter(r => r.kind !== ResourceKind.Kubernetes), - ]; - break; - } - return sorted; -} - -const aBeforeB = -1; -const aAfterB = 1; -const aEqualsB = 0; - -/** - * Evaluates the predicate and prioritizes the element matching the predicate over the element that - * doesn't. - * - * @example - * comparePredicate({color: 'green'}, {color: 'red'}, (el) => el.color === 'green') // => -1 (a before b) - * comparePredicate({color: 'red'}, {color: 'green'}, (el) => el.color === 'green') // => 1 (a after b) - * comparePredicate({color: 'blue'}, {color: 'pink'}, (el) => el.color === 'green') // => 0 (both are equal) - */ -function comparePredicate( - a: ElementType, - b: ElementType, - predicate: (resource: ElementType) => boolean -): -1 | 0 | 1 { - const aMatches = predicate(a); - const bMatches = predicate(b); - - if (aMatches && !bMatches) { - return aBeforeB; - } - - if (bMatches && !aMatches) { - return aAfterB; - } - - return aEqualsB; -} - -export function sortResources( - resources: ResourceSpec[], - preferences: UserPreferences, - onboardDiscover: OnboardDiscover | undefined -) { - const { preferredResources, hasPreferredResources } = - getPrioritizedResources(preferences); - const platform = getPlatform(); - - const sortedResources = [...resources]; - const accessible = sortedResources.filter(r => r.hasAccess); - const restricted = sortedResources.filter(r => !r.hasAccess); - - const hasNoResources = onboardDiscover && !onboardDiscover.hasResource; - const prefersServers = - hasPreferredResources && - preferredResources.includes( - resourceKindToPreferredResource(ResourceKind.Server) - ); - const prefersServersOrNoPreferences = - prefersServers || !hasPreferredResources; - const shouldShowConnectMyComputerFirst = - hasNoResources && - prefersServersOrNoPreferences && - isConnectMyComputerAvailable(accessible); - - // Sort accessible resources by: - // 1. os - // 2. preferred - // 3. guided - // 4. alphabetically - // - // When available on the given platform, Connect My Computer is put either as the first resource - // if the user has no resources, otherwise it's at the end of the guided group. - accessible.sort((a, b) => { - const compareAB = (predicate: (r: ResourceSpec) => boolean) => - comparePredicate(a, b, predicate); - const areBothGuided = !a.unguidedLink && !b.unguidedLink; - - // Special cases for Connect My Computer. - // Show Connect My Computer tile as the first resource. - if (shouldShowConnectMyComputerFirst) { - const prioritizeConnectMyComputer = compareAB( - r => r.kind === ResourceKind.ConnectMyComputer - ); - if (prioritizeConnectMyComputer) { - return prioritizeConnectMyComputer; - } - - // Within the guided group, deprioritize server tiles of the current user platform if Connect - // My Computer is available. - // - // If the user has no resources available in the cluster, we want to nudge them towards - // Connect My Computer rather than, say, standalone macOS setup. - // - // Only do this if the user doesn't explicitly prefer servers. If they prefer servers, we - // want the servers for their platform to be displayed in their usual place so that the user - // doesn't miss that Teleport supports them. - if (!prefersServers && areBothGuided) { - const deprioritizeServerForUserPlatform = compareAB( - r => !(r.kind == ResourceKind.Server && r.platform === platform) - ); - if (deprioritizeServerForUserPlatform) { - return deprioritizeServerForUserPlatform; - } - } - } else if (areBothGuided) { - // Show Connect My Computer tile as the last guided resource if the user already added some - // resources or they prefer other kinds of resources than servers. - const deprioritizeConnectMyComputer = compareAB( - r => r.kind !== ResourceKind.ConnectMyComputer - ); - if (deprioritizeConnectMyComputer) { - return deprioritizeConnectMyComputer; - } - } - - // Display platform resources first - const prioritizeUserPlatform = compareAB(r => r.platform === platform); - if (prioritizeUserPlatform) { - return prioritizeUserPlatform; - } - - // Display preferred resources second - if (hasPreferredResources) { - const prioritizePreferredResource = compareAB(r => - preferredResources.includes(resourceKindToPreferredResource(r.kind)) - ); - if (prioritizePreferredResource) { - return prioritizePreferredResource; - } - } - - // Display guided resources third - const prioritizeGuided = compareAB(r => !r.unguidedLink); - if (prioritizeGuided) { - return prioritizeGuided; - } - - // Alpha - return a.name.localeCompare(b.name); - }); - - // 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]; -} - -function isConnectMyComputerAvailable( - accessibleResources: ResourceSpec[] -): boolean { - return !!accessibleResources.find( - resource => resource.kind === ResourceKind.ConnectMyComputer - ); -} - -/** - * 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(Resource).length / 2 - 1; - const selectedAll = preferredResources.length === maxResources; - - return { - preferredResources: preferredResources, - hasPreferredResources: preferredResources.length > 0 && !selectedAll, - }; -} - -export function filterResources( - platform: Platform, - authType: AuthType, - resources: ResourceSpec[] -) { - return resources.filter(resource => { - const resourceSupportsPlatform = - !resource.supportedPlatforms?.length || - resource.supportedPlatforms.includes(platform); - - const resourceSupportsAuthType = - !resource.supportedAuthTypes?.length || - resource.supportedAuthTypes.includes(authType); - - return resourceSupportsPlatform && resourceSupportsAuthType; - }); -} - -function addHasAccessField( - acl: Acl, - resources: ResourceSpec[] -): ResourceSpec[] { - return resources.map(r => { - const hasAccess = checkHasAccess(acl, r.kind); - switch (r.kind) { - case ResourceKind.Database: - return { ...r, dbMeta: { ...r.dbMeta }, hasAccess }; - default: - return { ...r, hasAccess }; - } - }); -} - -const Grid = styled.div` +const Grid = styled.div<{ pinnedSection?: boolean }>` display: grid; - grid-template-columns: repeat(auto-fill, 320px); + grid-template-columns: repeat( + auto-fill, + ${p => (p.pinnedSection ? '250px' : '320px')} + ); column-gap: 10px; row-gap: 15px; `; - -const NewTabInCorner = styled(NewTab)` - position: absolute; - top: ${props => props.theme.space[3]}px; - right: ${props => props.theme.space[3]}px; - transition: color 0.3s; -`; - -const ResourceCard = styled.button<{ hasAccess?: boolean }>` - position: relative; - text-align: left; - background: ${props => props.theme.colors.spotBackground[0]}; - transition: all 0.3s; - - border: none; - border-radius: 8px; - padding: 12px; - color: ${props => props.theme.colors.text.main}; - line-height: inherit; - font-size: inherit; - font-family: inherit; - cursor: pointer; - - opacity: ${props => (props.hasAccess ? '1' : '0.45')}; - - &:focus-visible { - outline: none; - box-shadow: 0 0 0 3px ${props => props.theme.colors.brand}; - } - - &:hover, - &:focus-visible { - background: ${props => props.theme.colors.spotBackground[1]}; - - ${NewTabInCorner} { - color: ${props => props.theme.colors.text.slightlyMuted}; - } - } -`; - -const BadgeGuided = styled.div` - position: absolute; - background: ${props => props.theme.colors.brand}; - color: ${props => props.theme.colors.text.primaryInverse}; - padding: 0px 6px; - border-top-right-radius: 8px; - border-bottom-left-radius: 8px; - top: 0px; - right: 0px; - font-size: 10px; - line-height: 24px; -`; - -const InputWrapper = styled.div` - border-radius: 200px; - height: 40px; - border: 1px solid ${props => props.theme.colors.spotBackground[2]}; - transition: all 0.1s; - - &:hover, - &:focus-within, - &:active { - background: ${props => props.theme.colors.spotBackground[0]}; - } -`; - -const StyledInput = styled.input` - border: none; - outline: none; - box-sizing: border-box; - height: 100%; - width: 100%; - transition: all 0.2s; - color: ${props => props.theme.colors.text.main}; - background: transparent; - margin-right: ${props => props.theme.space[3]}px; - margin-bottom: ${props => props.theme.space[2]}px; - padding: ${props => props.theme.space[3]}px; -`; diff --git a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx new file mode 100644 index 0000000000000..15bcac0f4ce7a --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx @@ -0,0 +1,287 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useState, type ComponentPropsWithoutRef } from 'react'; +import styled from 'styled-components'; + +import { Box, Flex, Link, Text } from 'design'; +import { NewTab } from 'design/Icon'; +import { Theme } from 'design/theme'; +import { PinningSupport } from 'shared/components/UnifiedResources'; +import { PinButton } from 'shared/components/UnifiedResources/shared/PinButton'; + +import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge'; +import { + PermissionsErrorMessage, + ResourceKind, +} from 'teleport/Discover/Shared'; + +import { getResourcePretitle } from '.'; +import { DiscoverIcon } from './icons'; +import { SelectResourceSpec } from './resources'; + +export type Size = 'regular' | 'large'; + +export function Tile({ + resourceSpec, + size = 'regular', + isPinned = false, + onChangeShowApp, + onSelectResource, + onChangePin, + pinningSupport, +}: { + /** + * if true, renders a larger tile with larger icon to + * help differentiate pinned tiles from regular tiles. + */ + size?: Size; + isPinned: boolean; + resourceSpec: SelectResourceSpec; + onChangeShowApp(showApp: boolean): void; + onSelectResource(selectedResourceSpec: SelectResourceSpec): void; + pinningSupport: PinningSupport; + onChangePin(guideId: string): void; +}) { + const [pinHovered, setPinHovered] = useState(false); + + const title = resourceSpec.name; + const pretitle = getResourcePretitle(resourceSpec); + const select = () => { + if (!resourceSpec.hasAccess) { + return; + } + + onChangeShowApp(true); + onSelectResource(resourceSpec); + }; + + let resourceCardProps: ComponentPropsWithoutRef<'button' | typeof Link>; + + if (resourceSpec.kind === ResourceKind.Application && resourceSpec.isDialog) { + resourceCardProps = { + onClick: select, + onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(), + role: 'button', + }; + } else if (resourceSpec.unguidedLink) { + resourceCardProps = { + as: Link, + href: resourceSpec.hasAccess ? resourceSpec.unguidedLink : null, + target: '_blank', + style: { textDecoration: 'none' }, + role: 'link', + }; + } else { + resourceCardProps = { + onClick: () => resourceSpec.hasAccess && onSelectResource(resourceSpec), + onKeyUp: (e: KeyboardEvent) => { + if (e.key === 'Enter' && resourceSpec.hasAccess) { + onSelectResource(resourceSpec); + } + }, + role: 'button', + }; + } + + const wantLargeTile = size === 'large'; + + // There can be three types of click behavior with the resource cards: + // 1) If the resource has no interactive UI flow ("unguided"), + // clicking on the card will take a user to our docs page + // on a new tab. + // 2) If the resource is guided, we start the "flow" by + // taking user to the next step. + // 3) If the resource is kind 'Application', it will render the legacy + // popup modal where it shows user to add app manually or automatically. + return ( + setPinHovered(true)} + onMouseLeave={() => setPinHovered(false)} + {...resourceCardProps} + > + + {!resourceSpec.unguidedLink && resourceSpec.hasAccess && ( + Guided + )} + {!resourceSpec.hasAccess && ( + + + + )} + + + + + {resourceSpec.unguidedLink ? ( + + {title} + {resourceSpec.hasAccess && ( + + )} + + ) : ( + {title} + )} + {pretitle && ( + + {pretitle} + + )} + + + + onChangePin(resourceSpec.id)} + pinningSupport={pinningSupport} + /> + + + + + ); +} + +const NewTabIcon = styled(NewTab)` + transition: color 0.3s; +`; + +/** + * ResourceCard cannot be a button, even though it's used like a button + * since "PinButton.tsx" is rendered as its children. Otherwise it causes + * an error where "button cannot be nested within a button". + */ +const ResourceCard = styled.div` + position: relative; + + border-radius: ${props => props.theme.radii[3]}px; + transition: all 150ms; + + &:hover { + background-color: ${props => props.theme.colors.levels.surface}; + + // We use a pseudo element for the shadow with position: absolute in order + // to prevent the shadow from increasing the size of the layout and causing + // scrollbar flicker. + &:after { + box-shadow: ${props => props.theme.boxShadow[3]}; + border-radius: ${props => props.theme.radii[3]}px; + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + } + } +`; + +const InnerCard = styled.div<{ hasAccess?: boolean; wantLargeTile?: boolean }>` + align-items: flex-start; + display: inline-block; + box-sizing: border-box; + margin: 0; + appearance: auto; + text-align: left; + + height: ${p => (p.wantLargeTile ? '154px' : 'auto')}; + + width: 100%; + border: ${props => props.theme.borders[2]} + ${props => props.theme.colors.spotBackground[0]}; + border-radius: ${props => props.theme.radii[3]}px; + background-color: ${props => getBackgroundColor(props)}; + + padding: 12px; + color: ${props => props.theme.colors.text.main}; + line-height: inherit; + font-size: inherit; + font-family: inherit; + cursor: pointer; + + opacity: ${props => (props.hasAccess ? '1' : '0.45')}; + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px ${props => props.theme.colors.brand}; + } + + &:hover, + &:focus-visible { + // Make the border invisible instead of removing it, + // this is to prevent things from shifting due to the size change. + border: ${props => props.theme.borders[2]} rgba(0, 0, 0, 0); + + ${NewTabIcon} { + color: ${props => props.theme.colors.text.slightlyMuted}; + } + } +`; + +export const getBackgroundColor = (props: { + pinned?: boolean; + theme: Theme; +}) => { + if (props.pinned) { + return props.theme.colors.interactive.tonal.primary[1]; + } + return 'transparent'; +}; + +const BadgeGuided = styled.div` + position: absolute; + background: ${props => props.theme.colors.brand}; + color: ${props => props.theme.colors.text.primaryInverse}; + padding: 0px 6px; + border-top-right-radius: 8px; + border-bottom-left-radius: 8px; + top: 0px; + right: 0px; + font-size: 10px; + line-height: 18px; +`; + +const StyledText = styled(Text)<{ wantLargeTile?: boolean }>` + white-space: ${p => (p.wantLargeTile ? 'nowrap' : 'normal')}; + width: ${p => (p.wantLargeTile ? '155px' : 'auto')}; + font-weight: bold; +`; diff --git a/web/packages/teleport/src/Discover/SelectResource/icons.tsx b/web/packages/teleport/src/Discover/SelectResource/icons.tsx index 2b73cadc9180b..07ae623e4b14e 100644 --- a/web/packages/teleport/src/Discover/SelectResource/icons.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/icons.tsx @@ -20,8 +20,11 @@ import { ResourceIcon, ResourceIconName } from 'design/ResourceIcon'; interface DiscoverIconProps { name: ResourceIconName; + size?: 'large' | 'small'; } -export const DiscoverIcon = ({ name }: DiscoverIconProps) => ( - -); +export const DiscoverIcon = ({ name, size = 'small' }: DiscoverIconProps) => { + const width = size === 'small' ? '24px' : '72'; + const height = size === 'small' ? '24px' : '72'; + return ; +}; diff --git a/web/packages/teleport/src/Discover/SelectResource/index.ts b/web/packages/teleport/src/Discover/SelectResource/index.ts index f253c05ca928d..ab372429f3336 100644 --- a/web/packages/teleport/src/Discover/SelectResource/index.ts +++ b/web/packages/teleport/src/Discover/SelectResource/index.ts @@ -17,6 +17,9 @@ */ export { SelectResource } from './SelectResource'; -export { getResourcePretitle } from './resources'; -export { getDatabaseProtocol, getDefaultDatabasePort } from './databases'; +export { + getResourcePretitle, + getDatabaseProtocol, + getDefaultDatabasePort, +} from './resources'; export * from './types'; diff --git a/web/packages/teleport/src/Discover/SelectResource/databases.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx similarity index 79% rename from web/packages/teleport/src/Discover/SelectResource/databases.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx index a9f3d55112619..2a7e074615d37 100644 --- a/web/packages/teleport/src/Discover/SelectResource/databases.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx @@ -20,27 +20,27 @@ import { Platform } from 'design/platform'; import { DbProtocol } from 'shared/services/databases'; import { DiscoverEventResource } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; -import { ResourceKind } from '../Shared/ResourceKind'; -import { DatabaseEngine, DatabaseLocation, ResourceSpec } from './types'; - -const baseDatabaseKeywords = ['db', 'database', 'databases']; -const awsKeywords = [...baseDatabaseKeywords, 'aws', 'amazon web services']; -const gcpKeywords = [...baseDatabaseKeywords, 'gcp', 'google cloud platform']; -const selfhostedKeywords = [ - ...baseDatabaseKeywords, - 'self hosted', - 'self-hosted', -]; -const azureKeywords = [...baseDatabaseKeywords, 'microsoft azure']; +import { SelectResourceSpec } from '.'; +import { ResourceKind } from '../../Shared/ResourceKind'; +import { DatabaseEngine, DatabaseLocation } from '../types'; +import { + awsDatabaseKeywords, + azureKeywords, + baseDatabaseKeywords, + gcpKeywords, + selfHostedDatabaseKeywords, +} from './keywords'; // DATABASES_UNGUIDED_DOC are documentations that is not specific // to one type of database. -export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ +export const DATABASES_UNGUIDED_DOC: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsRdsProxyPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy PostgreSQL', - keywords: [...awsKeywords, 'rds', 'proxy', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'rds', 'proxy', 'postgresql'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -49,9 +49,16 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseAwsRdsProxySqlServer, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy SQL Server', - keywords: [...awsKeywords, 'rds', 'proxy', 'sql server', 'sqlserver'], + keywords: [ + ...awsDatabaseKeywords, + 'rds', + 'proxy', + 'sql server', + 'sqlserver', + ], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -60,9 +67,10 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseAwsRdsProxyMariaMySql, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy MariaDB/MySQL', - keywords: [...awsKeywords, 'rds', 'proxy', 'mariadb', 'mysql'], + keywords: [...awsDatabaseKeywords, 'rds', 'proxy', 'mariadb', 'mysql'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -71,6 +79,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseHighAvailability, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Doc }, name: 'High Availability', keywords: [...baseDatabaseKeywords, 'high availability', 'ha'], @@ -81,6 +90,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocHighAvailability, }, { + id: DiscoverGuideId.DatabaseDynamicRegistration, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Doc }, name: 'Dynamic Registration', keywords: [...baseDatabaseKeywords, 'dynamic registration'], @@ -92,11 +102,12 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ }, ]; -export const DATABASES_UNGUIDED: ResourceSpec[] = [ +export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsDynamoDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.DynamoDb }, name: 'DynamoDB', - keywords: [...awsKeywords, 'dynamodb'], + keywords: [...awsDatabaseKeywords, 'dynamodb'], kind: ResourceKind.Database, icon: 'dynamo', unguidedLink: @@ -104,9 +115,10 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDynamoDb, }, { + id: DiscoverGuideId.DatabaseAwsElastiCacheMemoryDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redis }, name: 'ElastiCache & MemoryDB', - keywords: [...awsKeywords, 'elasticache', 'memorydb', 'redis'], + keywords: [...awsDatabaseKeywords, 'elasticache', 'memorydb', 'redis'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -114,12 +126,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisElasticache, }, { + id: DiscoverGuideId.DatabaseAwsCassandraKeyspaces, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Cassandra, }, name: 'Keyspaces (Apache Cassandra)', - keywords: [...awsKeywords, 'keyspaces', 'apache', 'cassandra'], + keywords: [...awsDatabaseKeywords, 'keyspaces', 'apache', 'cassandra'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -127,9 +140,10 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCassandraKeyspaces, }, { + id: DiscoverGuideId.DatabaseAwsPostgresRedshift, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift PostgreSQL', - keywords: [...awsKeywords, 'redshift', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'redshift', 'postgresql'], kind: ResourceKind.Database, icon: 'redshift', unguidedLink: @@ -137,9 +151,10 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRedshift, }, { + id: DiscoverGuideId.DatabaseAwsRedshiftServerless, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift Serverless', - keywords: [...awsKeywords, 'redshift', 'serverless', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'redshift', 'serverless', 'postgresql'], kind: ResourceKind.Database, icon: 'redshift', unguidedLink: @@ -147,6 +162,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRedshiftServerless, }, { + id: DiscoverGuideId.DatabaseAzureRedis, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.Redis }, name: 'Cache for Redis', keywords: [...azureKeywords, 'cache', 'redis'], @@ -157,6 +173,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisAzureCache, }, { + id: DiscoverGuideId.DatabaseAzurePostgres, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.Postgres, @@ -170,6 +187,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresAzure, }, { + id: DiscoverGuideId.DatabaseAzureMysql, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.MySql }, name: 'MySQL', keywords: [...azureKeywords, 'mysql'], @@ -180,6 +198,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlAzure, }, { + id: DiscoverGuideId.DatabaseAzureSqlServerAd, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.SqlServer, @@ -201,13 +220,14 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ platform: Platform.Windows, }, { + id: DiscoverGuideId.DatabaseAwsSqlServerAd, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.SqlServer, }, name: 'RDS SQL Server', keywords: [ - ...awsKeywords, + ...awsDatabaseKeywords, 'rds', 'microsoft', 'active directory', @@ -224,6 +244,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ platform: Platform.Windows, }, { + id: DiscoverGuideId.DatabaseGcpMysqlCloudSql, dbMeta: { location: DatabaseLocation.Gcp, engine: DatabaseEngine.MySql }, name: 'Cloud SQL MySQL', keywords: [...gcpKeywords, 'mysql'], @@ -234,6 +255,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlGcp, }, { + id: DiscoverGuideId.DatabaseGcpPostgresCloudSql, dbMeta: { location: DatabaseLocation.Gcp, engine: DatabaseEngine.Postgres }, name: 'Cloud SQL PostgreSQL', keywords: [...gcpKeywords, 'postgresql'], @@ -244,6 +266,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresGcp, }, { + id: DiscoverGuideId.DatabaseMongoAtlas, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.MongoDb, @@ -257,12 +280,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMongodbAtlas, }, { + id: DiscoverGuideId.DatabaseCassandraScyllaDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Cassandra, }, name: 'Cassandra & ScyllaDB', - keywords: [...selfhostedKeywords, 'cassandra scylladb'], + keywords: [...selfHostedDatabaseKeywords, 'cassandra scylladb'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -270,12 +294,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCassandraSelfHosted, }, { + id: DiscoverGuideId.DatabaseCockroachDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.CockroachDb, }, name: 'CockroachDB', - keywords: [...selfhostedKeywords, 'cockroachdb'], + keywords: [...selfHostedDatabaseKeywords, 'cockroachdb'], kind: ResourceKind.Database, icon: 'cockroach', unguidedLink: @@ -283,12 +308,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCockroachDbSelfHosted, }, { + id: DiscoverGuideId.DatabaseElasticSearch, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.ElasticSearch, }, name: 'Elasticsearch', - keywords: [...selfhostedKeywords, 'elasticsearch', 'es'], + keywords: [...selfHostedDatabaseKeywords, 'elasticsearch', 'es'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -296,12 +322,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseElasticSearchSelfHosted, }, { + id: DiscoverGuideId.DatabaseMongoDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.MongoDb, }, name: 'MongoDB', - keywords: [...selfhostedKeywords, 'mongodb'], + keywords: [...selfHostedDatabaseKeywords, 'mongodb'], kind: ResourceKind.Database, icon: 'mongo', unguidedLink: @@ -309,12 +336,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMongodbSelfHosted, }, { + id: DiscoverGuideId.DatabaseRedis, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Redis, }, name: 'Redis', - keywords: [...selfhostedKeywords, 'redis'], + keywords: [...selfHostedDatabaseKeywords, 'redis'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -322,12 +350,13 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisSelfHosted, }, { + id: DiscoverGuideId.DatabaseRedisCluster, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Redis, }, name: 'Redis Cluster', - keywords: [...selfhostedKeywords, 'redis cluster'], + keywords: [...selfHostedDatabaseKeywords, 'redis cluster'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -335,6 +364,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisClusterSelfHosted, }, { + id: DiscoverGuideId.DatabaseSnowflake, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Snowflake, @@ -349,66 +379,72 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ }, ]; -export const DATABASES: ResourceSpec[] = [ +export const DATABASES: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsRdsPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Postgres, }, name: 'RDS PostgreSQL', - keywords: [...awsKeywords, 'rds postgresql'], + keywords: [...awsDatabaseKeywords, 'rds postgresql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabasePostgresRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsAuroraPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.AuroraPostgres, }, name: 'RDS Aurora PostgreSQL', - keywords: [...awsKeywords, 'rds aurora postgresql'], + keywords: [...awsDatabaseKeywords, 'rds aurora postgresql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabasePostgresRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsMysqlMariaDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.MySql }, name: 'RDS MySQL/MariaDB', - keywords: [...awsKeywords, 'rds mysql mariadb'], + keywords: [...awsDatabaseKeywords, 'rds mysql mariadb'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabaseMysqlRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsAuroraMysql, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.AuroraMysql, }, name: 'RDS Aurora MySQL', - keywords: [...awsKeywords, 'rds aurora mysql'], + keywords: [...awsDatabaseKeywords, 'rds aurora mysql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabaseMysqlRds, }, { + id: DiscoverGuideId.DatabasePostgres, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Postgres, }, name: 'PostgreSQL', - keywords: [...selfhostedKeywords, 'postgresql'], + keywords: [...selfHostedDatabaseKeywords, 'postgresql'], kind: ResourceKind.Database, icon: 'postgres', event: DiscoverEventResource.DatabasePostgresSelfHosted, }, { + id: DiscoverGuideId.DatabaseMysql, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.MySql, }, name: 'MySQL/MariaDB', - keywords: [...selfhostedKeywords, 'mysql mariadb'], + keywords: [...selfHostedDatabaseKeywords, 'mysql mariadb'], kind: ResourceKind.Database, icon: 'selfhosted', event: DiscoverEventResource.DatabaseMysqlSelfHosted, diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/index.ts b/web/packages/teleport/src/Discover/SelectResource/resources/index.ts new file mode 100644 index 0000000000000..032144296417b --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/resources/index.ts @@ -0,0 +1,21 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export * from './databases'; +export * from './resources'; +export * from './resourcesE'; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts b/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts new file mode 100644 index 0000000000000..489ba11058154 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts @@ -0,0 +1,35 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export const baseServerKeywords = ['server', 'node', 'ssh']; +export const awsKeywords = ['aws', 'amazon', 'amazon web services']; +export const kubeKeywords = ['kubernetes', 'k8s', 'kubes', 'cluster']; +export const selfHostedKeywords = ['self hosted', 'self-hosted']; + +export const baseDatabaseKeywords = ['db', 'database', 'databases']; +export const awsDatabaseKeywords = [...baseDatabaseKeywords, ...awsKeywords]; +export const gcpKeywords = [ + ...baseDatabaseKeywords, + 'gcp', + 'google cloud platform', +]; +export const selfHostedDatabaseKeywords = [ + ...baseDatabaseKeywords, + ...selfHostedKeywords, +]; +export const azureKeywords = [...baseDatabaseKeywords, 'microsoft azure']; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx similarity index 74% rename from web/packages/teleport/src/Discover/SelectResource/resources.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx index f089fe9dc4db2..5f2e5c29cb3b5 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx @@ -23,27 +23,31 @@ import { DiscoverDiscoveryConfigMethod, DiscoverEventResource, } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; -import { ResourceKind } from '../Shared/ResourceKind'; -import { - DATABASES, - DATABASES_UNGUIDED, - DATABASES_UNGUIDED_DOC, -} from './databases'; +import { ResourceKind } from '../../Shared/ResourceKind'; import { DatabaseEngine, DatabaseLocation, KubeLocation, ResourceSpec, ServerLocation, -} from './types'; - -const baseServerKeywords = ['server', 'node', 'ssh']; -const awsKeywords = ['aws', 'amazon', 'amazon web services']; -const kubeKeywords = ['kubernetes', 'k8s', 'kubes', 'cluster']; +} from '../types'; +import { + DATABASES, + DATABASES_UNGUIDED, + DATABASES_UNGUIDED_DOC, +} from './databases'; +import { + awsKeywords, + baseServerKeywords, + kubeKeywords, + selfHostedKeywords, +} from './keywords'; -export const SERVERS: ResourceSpec[] = [ +export const SERVERS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.ServerLinuxUbuntu, name: 'Ubuntu 18.04+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'ubuntu', 'linux'], @@ -52,6 +56,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxDebian, name: 'Debian 11+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'debian', 'linux'], @@ -60,6 +65,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxRhelCentos, name: 'RHEL 8+/CentOS Stream 9+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'rhel', 'redhat', 'centos', 'linux'], @@ -68,6 +74,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxAmazon, name: 'Amazon Linux 2/2023', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'amazon', 'linux'], @@ -76,6 +83,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerMac, name: 'macOS', kind: ResourceKind.Server, keywords: [ @@ -92,6 +100,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.macOS, }, { + id: DiscoverGuideId.ServerAwsEc2Ssm, name: 'EC2 Auto Enrollment via SSM', kind: ResourceKind.Server, keywords: [ @@ -111,6 +120,7 @@ export const SERVERS: ResourceSpec[] = [ }, }, { + id: DiscoverGuideId.ConnectMyComputer, name: 'Connect My Computer', kind: ResourceKind.ConnectMyComputer, keywords: [ @@ -127,9 +137,10 @@ export const SERVERS: ResourceSpec[] = [ }, ]; -export const APPLICATIONS: ResourceSpec[] = [ +export const APPLICATIONS: SelectResourceSpec[] = [ { - name: 'Application', + id: DiscoverGuideId.ApplicationWebHttpProxy, + name: 'Web Application', kind: ResourceKind.Application, keywords: ['application'], icon: 'application', @@ -137,6 +148,7 @@ export const APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.ApplicationHttp, }, { + id: DiscoverGuideId.ApplicationAwsCliConsole, name: 'AWS CLI/Console Access', kind: ResourceKind.Application, keywords: [...awsKeywords, 'application', 'cli', 'console access'], @@ -146,8 +158,9 @@ export const APPLICATIONS: ResourceSpec[] = [ }, ]; -export const WINDOWS_DESKTOPS: ResourceSpec[] = [ +export const WINDOWS_DESKTOPS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.WindowsDesktopsActiveDirectory, name: 'Active Directory Users', kind: ResourceKind.Desktop, keywords: ['windows', 'desktop', 'microsoft active directory', 'ad'], @@ -157,6 +170,7 @@ export const WINDOWS_DESKTOPS: ResourceSpec[] = [ 'https://goteleport.com/docs/enroll-resources/desktop-access/active-directory/', }, { + id: DiscoverGuideId.WindowsDesktopsLocal, name: 'Local Users', kind: ResourceKind.Desktop, keywords: ['windows', 'desktop', 'non-ad', 'local'], @@ -167,16 +181,18 @@ export const WINDOWS_DESKTOPS: ResourceSpec[] = [ }, ]; -export const KUBERNETES: ResourceSpec[] = [ +export const KUBERNETES: SelectResourceSpec[] = [ { + id: DiscoverGuideId.Kubernetes, name: 'Kubernetes', kind: ResourceKind.Kubernetes, - keywords: [...kubeKeywords], + keywords: [...kubeKeywords, ...selfHostedKeywords], icon: 'kube', event: DiscoverEventResource.Kubernetes, kubeMeta: { location: KubeLocation.SelfHosted }, }, { + id: DiscoverGuideId.KubernetesAwsEks, name: 'EKS', kind: ResourceKind.Kubernetes, keywords: [...awsKeywords, ...kubeKeywords, 'eks', 'elastic', 'service'], @@ -186,7 +202,7 @@ export const KUBERNETES: ResourceSpec[] = [ }, ]; -export const BASE_RESOURCES: ResourceSpec[] = [ +export const BASE_RESOURCES: SelectResourceSpec[] = [ ...APPLICATIONS, ...KUBERNETES, ...WINDOWS_DESKTOPS, @@ -196,7 +212,7 @@ export const BASE_RESOURCES: ResourceSpec[] = [ ...DATABASES_UNGUIDED_DOC, ]; -export function getResourcePretitle(r: ResourceSpec) { +export function getResourcePretitle(r: SelectResourceSpec) { if (!r) { return ''; } @@ -220,6 +236,15 @@ export function getResourcePretitle(r: ResourceSpec) { if (r.dbMeta.engine === DatabaseEngine.Doc) { return 'Database'; } + if ( + r.id === DiscoverGuideId.DatabaseSnowflake || + r.id === DiscoverGuideId.DatabaseMongoAtlas + ) { + return 'Database as a Service'; + } + if (r.id === DiscoverGuideId.DatabaseDynamicRegistration) { + return 'Self-Hosted'; + } } break; case ResourceKind.Desktop: @@ -240,10 +265,29 @@ export function getResourcePretitle(r: ResourceSpec) { if (r.nodeMeta?.location === ServerLocation.Aws) { return 'Amazon Web Services (AWS)'; } - return 'Server'; + return 'SSH'; case ResourceKind.SamlApplication: + if ( + r.id === DiscoverGuideId.ApplicationSamlGeneric || + r.id === DiscoverGuideId.ApplicationSamlGrafana + ) { + return 'Teleport as IDP'; + } return 'SAML Application'; + case ResourceKind.ConnectMyComputer: + return 'SSH'; + case ResourceKind.Application: + if (r.id === DiscoverGuideId.ApplicationAwsCliConsole) { + return 'Amazon Web Services (AWS)'; + } + if (r.id === DiscoverGuideId.ApplicationWebHttpProxy) { + return 'HTTP Proxy'; + } } return ''; } + +export type SelectResourceSpec = ResourceSpec & { + id: DiscoverGuideId; +}; diff --git a/web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx similarity index 80% rename from web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx index 2cba11ef39d34..8ec54b0a13238 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx @@ -18,12 +18,14 @@ import { SamlServiceProviderPreset } from 'teleport/services/samlidp/types'; import { DiscoverEventResource } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; -import { ResourceKind } from '../Shared'; -import { ResourceSpec } from './types'; +import { SelectResourceSpec } from '.'; +import { ResourceKind } from '../../Shared'; -export const SAML_APPLICATIONS: ResourceSpec[] = [ +export const SAML_APPLICATIONS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.ApplicationSamlGeneric, name: 'SAML Application (Generic)', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.Unspecified }, @@ -32,7 +34,8 @@ export const SAML_APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.SamlApplication, }, { - name: 'Grafana', + id: DiscoverGuideId.ApplicationSamlGrafana, + name: 'Grafana SAML', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.Grafana }, keywords: ['saml', 'sso', 'application', 'idp', 'grafana'], @@ -40,6 +43,7 @@ export const SAML_APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.SamlApplication, }, { + id: DiscoverGuideId.ApplicationSamlWorkforceIdentityFederation, name: 'Workforce Identity Federation', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.GcpWorkforce }, diff --git a/web/packages/teleport/src/Discover/SelectResource/testUtils.ts b/web/packages/teleport/src/Discover/SelectResource/testUtils.ts new file mode 100644 index 0000000000000..87241db882ea0 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/testUtils.ts @@ -0,0 +1,159 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ResourceKind } from '../Shared'; +import { SelectResourceSpec } from './resources'; + +export const makeResourceSpec = ( + overrides: Partial = {} +): SelectResourceSpec => { + return Object.assign( + { + id: '', + name: '', + kind: ResourceKind.Application, + icon: '', + event: null, + keywords: [], + hasAccess: true, + }, + overrides + ); +}; + +export const t_Application_NoAccess = makeResourceSpec({ + name: 'tango', + kind: ResourceKind.Application, + hasAccess: false, +}); +export const u_Database_NoAccess = makeResourceSpec({ + name: 'uniform', + kind: ResourceKind.Database, + hasAccess: false, +}); +export const v_Desktop_NoAccess = makeResourceSpec({ + name: 'victor', + kind: ResourceKind.Desktop, + hasAccess: false, +}); +export const w_Kubernetes_NoAccess = makeResourceSpec({ + name: 'whiskey', + kind: ResourceKind.Kubernetes, + hasAccess: false, +}); +export const x_Server_NoAccess = makeResourceSpec({ + name: 'xray', + kind: ResourceKind.Server, + hasAccess: false, +}); +export const y_Saml_NoAccess = makeResourceSpec({ + name: 'yankee', + kind: ResourceKind.SamlApplication, + hasAccess: false, +}); +export const z_Discovery_NoAccess = makeResourceSpec({ + name: 'zulu', + kind: ResourceKind.Discovery, + hasAccess: false, +}); + +export const NoAccessList: SelectResourceSpec[] = [ + t_Application_NoAccess, + u_Database_NoAccess, + v_Desktop_NoAccess, + w_Kubernetes_NoAccess, + x_Server_NoAccess, + y_Saml_NoAccess, + z_Discovery_NoAccess, +]; + +export const c_ApplicationGcp = makeResourceSpec({ + name: 'charlie', + kind: ResourceKind.Application, + keywords: ['gcp'], +}); +export const a_DatabaseAws = makeResourceSpec({ + name: 'alpha', + kind: ResourceKind.Database, + keywords: ['aws'], +}); +export const l_DesktopAzure = makeResourceSpec({ + name: 'linux', + kind: ResourceKind.Desktop, + keywords: ['azure'], +}); +export const e_KubernetesSelfHosted_unguided = makeResourceSpec({ + name: 'echo', + kind: ResourceKind.Kubernetes, + unguidedLink: 'test.com', + keywords: ['self-hosted'], +}); +export const f_Server = makeResourceSpec({ + name: 'foxtrot', + kind: ResourceKind.Server, +}); +export const d_Saml = makeResourceSpec({ + name: 'delta', + kind: ResourceKind.SamlApplication, +}); +export const g_Application = makeResourceSpec({ + name: 'golf', + kind: ResourceKind.Application, +}); +export const k_Database = makeResourceSpec({ + name: 'kilo', + kind: ResourceKind.Database, +}); +export const i_Desktop = makeResourceSpec({ + name: 'india', + kind: ResourceKind.Desktop, +}); +export const j_Kubernetes = makeResourceSpec({ + name: 'juliette', + kind: ResourceKind.Kubernetes, +}); +export const h_Server = makeResourceSpec({ + name: 'hotel', + kind: ResourceKind.Server, +}); +export const l_Saml = makeResourceSpec({ + name: 'lima', + kind: ResourceKind.SamlApplication, +}); + +export const kindBasedList: SelectResourceSpec[] = [ + c_ApplicationGcp, + a_DatabaseAws, + t_Application_NoAccess, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, + u_Database_NoAccess, + f_Server, + w_Kubernetes_NoAccess, + d_Saml, + v_Desktop_NoAccess, + g_Application, + x_Server_NoAccess, + k_Database, + i_Desktop, + z_Discovery_NoAccess, + j_Kubernetes, + h_Server, + y_Saml_NoAccess, + l_Saml, +]; diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts new file mode 100644 index 0000000000000..01fadfed5ec48 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts @@ -0,0 +1,65 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Acl } from 'teleport/services/user'; + +import { ResourceKind } from '../../Shared'; +import { SelectResourceSpec } from '../resources'; + +function checkHasAccess(acl: Acl, resourceKind: ResourceKind) { + const basePerm = acl.tokens.create; + if (!basePerm) { + return false; + } + + switch (resourceKind) { + case ResourceKind.Application: + return acl.appServers.read && acl.appServers.list; + case ResourceKind.Database: + return acl.dbServers.read && acl.dbServers.list; + case ResourceKind.Desktop: + return acl.desktops.read && acl.desktops.list; + case ResourceKind.Kubernetes: + return acl.kubeServers.read && acl.kubeServers.list; + case ResourceKind.Server: + return acl.nodes.list; + case ResourceKind.SamlApplication: + return acl.samlIdpServiceProvider.create; + case ResourceKind.ConnectMyComputer: + // This is probably already true since without this permission the user wouldn't be able to + // add any other resource, but let's just leave it for completeness sake. + return acl.tokens.create; + default: + return false; + } +} + +export function addHasAccessField( + acl: Acl, + resources: SelectResourceSpec[] +): SelectResourceSpec[] { + return resources.map(r => { + const hasAccess = checkHasAccess(acl, r.kind); + switch (r.kind) { + case ResourceKind.Database: + return { ...r, dbMeta: { ...r.dbMeta }, hasAccess }; + default: + return { ...r, hasAccess }; + } + }); +} diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts new file mode 100644 index 0000000000000..ccc0d53ffdf6c --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts @@ -0,0 +1,156 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { SelectResourceSpec } from '../resources'; +import { + a_DatabaseAws, + c_ApplicationGcp, + e_KubernetesSelfHosted_unguided, + f_Server, + l_DesktopAzure, + t_Application_NoAccess, +} from '../testUtils'; +import { filterResources, Filters } from './filters'; + +const resources: SelectResourceSpec[] = [ + c_ApplicationGcp, + t_Application_NoAccess, + a_DatabaseAws, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, + f_Server, +]; + +describe('filters by resource types', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no filter', + filter: { resourceTypes: [], hostingPlatforms: [] }, + expected: resources, + }, + { + name: 'filter by application', + filter: { resourceTypes: ['app'], hostingPlatforms: [] }, + expected: [c_ApplicationGcp, t_Application_NoAccess], + }, + { + name: 'filter by database', + filter: { resourceTypes: ['db'], hostingPlatforms: [] }, + expected: [a_DatabaseAws], + }, + { + name: 'filter by desktop', + filter: { resourceTypes: ['desktops'], hostingPlatforms: [] }, + expected: [l_DesktopAzure], + }, + { + name: 'filter by kuberenetes', + filter: { resourceTypes: ['kube'], hostingPlatforms: [] }, + expected: [e_KubernetesSelfHosted_unguided], + }, + { + name: 'filter by server', + filter: { resourceTypes: ['server'], hostingPlatforms: [] }, + expected: [f_Server], + }, + { + name: 'filter by server and app', + filter: { resourceTypes: ['app', 'server'], hostingPlatforms: [] }, + expected: [c_ApplicationGcp, t_Application_NoAccess, f_Server], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); + +describe('filters by hosting platform', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no filter', + filter: { resourceTypes: [], hostingPlatforms: [] }, + expected: resources, + }, + { + name: 'filter by aws', + filter: { resourceTypes: [], hostingPlatforms: ['aws'] }, + expected: [a_DatabaseAws], + }, + { + name: 'filter by azure', + filter: { resourceTypes: [], hostingPlatforms: ['azure'] }, + expected: [l_DesktopAzure], + }, + { + name: 'filter by gcp', + filter: { resourceTypes: [], hostingPlatforms: ['gcp'] }, + expected: [c_ApplicationGcp], + }, + { + name: 'filter by self-hosted', + filter: { resourceTypes: [], hostingPlatforms: ['self-hosted'] }, + expected: [e_KubernetesSelfHosted_unguided], + }, + { + name: 'filter by aws and azure', + filter: { resourceTypes: [], hostingPlatforms: ['aws', 'azure'] }, + expected: [a_DatabaseAws, l_DesktopAzure], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); + +describe('filters by resource types and hosting platform', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no results found', + filter: { resourceTypes: ['app'], hostingPlatforms: ['aws'] }, + expected: [], + }, + { + name: 'filter by app and gcp', + filter: { resourceTypes: ['app'], hostingPlatforms: ['gcp'] }, + expected: [c_ApplicationGcp], + }, + { + name: 'filter by app, kube and self-hosted', + filter: { + resourceTypes: ['app', 'kube'], + hostingPlatforms: ['self-hosted'], + }, + expected: [e_KubernetesSelfHosted_unguided], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts new file mode 100644 index 0000000000000..6fd058a48fada --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts @@ -0,0 +1,153 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Platform } from 'design/platform'; + +import { ResourceKind } from 'teleport/Discover/Shared'; +import { AuthType } from 'teleport/services/user'; + +import { SelectResourceSpec } from '../resources'; + +export function filterBySupportedPlatformsAndAuthTypes( + platform: Platform, + authType: AuthType, + resources: SelectResourceSpec[] +) { + return resources.filter(resource => { + const resourceSupportsPlatform = + !resource.supportedPlatforms?.length || + resource.supportedPlatforms.includes(platform); + + const resourceSupportsAuthType = + !resource.supportedAuthTypes?.length || + resource.supportedAuthTypes.includes(authType); + + return resourceSupportsPlatform && resourceSupportsAuthType; + }); +} + +export const resourceTypeOptions = [ + { value: 'app', label: 'Applications' }, + { value: 'db', label: 'Database' }, + { value: 'desktops', label: 'Desktops' }, + { value: 'kube', label: 'Kubernetes' }, + { value: 'server', label: 'SSH' }, +] as const satisfies { value: string; label: string }[]; + +type ResourceType = Extract< + (typeof resourceTypeOptions)[number], + { value: string } +>['value']; + +export const hostingPlatformOptions = [ + { value: 'aws', label: 'Amazon Web Services (AWS)' }, + { value: 'azure', label: 'Microsoft Azure' }, + { value: 'gcp', label: 'Google Cloud Services (GCP)' }, + { value: 'self-hosted', label: 'Self-Hosted' }, +] as const satisfies { value: string; label: string }[]; + +type HostingPlatform = Extract< + (typeof hostingPlatformOptions)[number], + { value: string } +>['value']; + +export type Filters = { + resourceTypes?: ResourceType[]; + hostingPlatforms?: HostingPlatform[]; +}; + +export function filterResources( + resources: SelectResourceSpec[], + filters: Filters +) { + if ( + !resources.length && + !filters.resourceTypes && + !filters.hostingPlatforms + ) { + return resources; + } + + let filtered = [...resources]; + if (filters.resourceTypes.length) { + const resourceTypes = filters.resourceTypes; + filtered = filtered.filter(r => { + if ( + resourceTypes.includes('app') && + (r.kind === ResourceKind.Application || + r.kind === ResourceKind.SamlApplication) + ) { + return true; + } + if (resourceTypes.includes('db') && r.kind === ResourceKind.Database) { + return true; + } + if ( + resourceTypes.includes('desktops') && + r.kind === ResourceKind.Desktop + ) { + return true; + } + if ( + resourceTypes.includes('kube') && + r.kind === ResourceKind.Kubernetes + ) { + return true; + } + if ( + resourceTypes.includes('server') && + (r.kind === ResourceKind.Server || + r.kind === ResourceKind.ConnectMyComputer) + ) { + return true; + } + }); + } + + if (filters.hostingPlatforms.length) { + const hostingPlatforms = filters.hostingPlatforms; + filtered = filtered.filter(r => { + if ( + hostingPlatforms.includes('aws') && + r.keywords.some(k => k.toLowerCase().includes('aws')) + ) { + return true; + } + if ( + hostingPlatforms.includes('azure') && + r.keywords.some(k => k.toLowerCase().includes('azure')) + ) { + return true; + } + if ( + hostingPlatforms.includes('gcp') && + r.keywords.some(k => k.toLowerCase().includes('gcp')) + ) { + return true; + } + if ( + hostingPlatforms.includes('self-hosted') && + r.keywords.some(k => k.toLowerCase().includes('self-hosted')) + ) { + return true; + } + }); + } + + return filtered; +} diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts b/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts new file mode 100644 index 0000000000000..a41f45372d3c4 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts @@ -0,0 +1,57 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; + +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; + +import { SelectResourceSpec } from '../resources'; + +export const defaultPins = [ + DiscoverGuideId.ConnectMyComputer, + DiscoverGuideId.Kubernetes, + DiscoverGuideId.ApplicationWebHttpProxy, + // TODO(kimlisa): add linux server, once they are all consolidated into one +]; + +/** + * Only returns the defaults of resources that is available to the user. + */ +export function getDefaultPins(availableResources: SelectResourceSpec[]) { + return availableResources + .filter(r => defaultPins.includes(r.id)) + .map(r => r.id); +} + +/** + * Returns pins from preference if any or default pins. + */ +export function getPins(preferences: DiscoverResourcePreference) { + if (!preferences.discoverResourcePreferences) { + return []; + } + + if (!preferences.discoverResourcePreferences.discoverGuide) { + return defaultPins; + } + + return preferences.discoverResourcePreferences.discoverGuide.pinned || []; +} + +export type DiscoverResourcePreference = Partial< + Pick +>; diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts new file mode 100644 index 0000000000000..fbf5884fd165b --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts @@ -0,0 +1,263 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { getPlatform } from 'design/platform'; +import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb'; +import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; + +import { OnboardDiscover } from 'teleport/services/user'; + +import { ResourceKind } from '../../Shared'; +import { resourceKindToPreferredResource } from '../../Shared/ResourceKind'; +import { getMarketingTermMatches } from '../getMarketingTermMatches'; +import { SelectResourceSpec } from '../resources'; +import { PrioritizedResources, SearchResource } from '../types'; + +function isConnectMyComputerAvailable( + accessibleResources: SelectResourceSpec[] +): boolean { + return !!accessibleResources.find( + resource => resource.kind === ResourceKind.ConnectMyComputer + ); +} + +export function sortResourcesByPreferences( + resources: SelectResourceSpec[], + preferences: UserPreferences, + onboardDiscover: OnboardDiscover | undefined +) { + const { preferredResources, hasPreferredResources } = + getPrioritizedResources(preferences); + const platform = getPlatform(); + + const sortedResources = [...resources]; + const accessible = sortedResources.filter(r => r.hasAccess); + const restricted = sortedResources.filter(r => !r.hasAccess); + + const hasNoResources = onboardDiscover && !onboardDiscover.hasResource; + const prefersServers = + hasPreferredResources && + preferredResources.includes( + resourceKindToPreferredResource(ResourceKind.Server) + ); + const prefersServersOrNoPreferences = + prefersServers || !hasPreferredResources; + const shouldShowConnectMyComputerFirst = + hasNoResources && + prefersServersOrNoPreferences && + isConnectMyComputerAvailable(accessible); + + // Sort accessible resources by: + // 1. os + // 2. preferred + // 3. guided + // 4. alphabetically + // + // When available on the given platform, Connect My Computer is put either as the first resource + // if the user has no resources, otherwise it's at the end of the guided group. + accessible.sort((a, b) => { + const compareAB = (predicate: (r: SelectResourceSpec) => boolean) => + comparePredicate(a, b, predicate); + const areBothGuided = !a.unguidedLink && !b.unguidedLink; + + // Special cases for Connect My Computer. + // Show Connect My Computer tile as the first resource. + if (shouldShowConnectMyComputerFirst) { + const prioritizeConnectMyComputer = compareAB( + r => r.kind === ResourceKind.ConnectMyComputer + ); + if (prioritizeConnectMyComputer) { + return prioritizeConnectMyComputer; + } + + // Within the guided group, deprioritize server tiles of the current user platform if Connect + // My Computer is available. + // + // If the user has no resources available in the cluster, we want to nudge them towards + // Connect My Computer rather than, say, standalone macOS setup. + // + // Only do this if the user doesn't explicitly prefer servers. If they prefer servers, we + // want the servers for their platform to be displayed in their usual place so that the user + // doesn't miss that Teleport supports them. + if (!prefersServers && areBothGuided) { + const deprioritizeServerForUserPlatform = compareAB( + r => !(r.kind == ResourceKind.Server && r.platform === platform) + ); + if (deprioritizeServerForUserPlatform) { + return deprioritizeServerForUserPlatform; + } + } + } else if (areBothGuided) { + // Show Connect My Computer tile as the last guided resource if the user already added some + // resources or they prefer other kinds of resources than servers. + const deprioritizeConnectMyComputer = compareAB( + r => r.kind !== ResourceKind.ConnectMyComputer + ); + if (deprioritizeConnectMyComputer) { + return deprioritizeConnectMyComputer; + } + } + + // Display platform resources first + const prioritizeUserPlatform = compareAB(r => r.platform === platform); + if (prioritizeUserPlatform) { + return prioritizeUserPlatform; + } + + // Display preferred resources second + if (hasPreferredResources) { + const prioritizePreferredResource = compareAB(r => + preferredResources.includes(resourceKindToPreferredResource(r.kind)) + ); + if (prioritizePreferredResource) { + return prioritizePreferredResource; + } + } + + // Display guided resources third + const prioritizeGuided = compareAB(r => !r.unguidedLink); + if (prioritizeGuided) { + return prioritizeGuided; + } + + // Alpha + return a.name.localeCompare(b.name); + }); + + // 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(Resource).length / 2 - 1; + const selectedAll = preferredResources.length === maxResources; + + return { + preferredResources: preferredResources, + hasPreferredResources: preferredResources.length > 0 && !selectedAll, + }; +} + +const aBeforeB = -1; +const aAfterB = 1; +const aEqualsB = 0; + +/** + * Evaluates the predicate and prioritizes the element matching the predicate over the element that + * doesn't. + * + * @example + * comparePredicate({color: 'green'}, {color: 'red'}, (el) => el.color === 'green') // => -1 (a before b) + * comparePredicate({color: 'red'}, {color: 'green'}, (el) => el.color === 'green') // => 1 (a after b) + * comparePredicate({color: 'blue'}, {color: 'pink'}, (el) => el.color === 'green') // => 0 (both are equal) + */ +function comparePredicate( + a: ElementType, + b: ElementType, + predicate: (resource: ElementType) => boolean +): -1 | 0 | 1 { + const aMatches = predicate(a); + const bMatches = predicate(b); + + if (aMatches && !bMatches) { + return aBeforeB; + } + + if (bMatches && !aMatches) { + return aAfterB; + } + + return aEqualsB; +} + +export function sortResourcesByKind( + resourceKind: SearchResource, + resources: SelectResourceSpec[] +) { + let sorted: SelectResourceSpec[] = []; + switch (resourceKind) { + case SearchResource.SERVER: + sorted = [ + ...resources.filter(r => r.kind === ResourceKind.Server), + ...resources.filter(r => r.kind !== ResourceKind.Server), + ]; + break; + case SearchResource.APPLICATION: + sorted = [ + ...resources.filter(r => r.kind === ResourceKind.Application), + ...resources.filter(r => r.kind !== ResourceKind.Application), + ]; + break; + case SearchResource.DATABASE: + sorted = [ + ...resources.filter(r => r.kind === ResourceKind.Database), + ...resources.filter(r => r.kind !== ResourceKind.Database), + ]; + break; + case SearchResource.DESKTOP: + sorted = [ + ...resources.filter(r => r.kind === ResourceKind.Desktop), + ...resources.filter(r => r.kind !== ResourceKind.Desktop), + ]; + break; + case SearchResource.KUBERNETES: + sorted = [ + ...resources.filter(r => r.kind === ResourceKind.Kubernetes), + ...resources.filter(r => r.kind !== ResourceKind.Kubernetes), + ]; + break; + } + return sorted; +} diff --git a/web/packages/teleport/src/Discover/testUtils.ts b/web/packages/teleport/src/Discover/testUtils.ts new file mode 100644 index 0000000000000..ae22fa46aaacb --- /dev/null +++ b/web/packages/teleport/src/Discover/testUtils.ts @@ -0,0 +1,34 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Size } from './SelectResource/Tile'; +import { ResourceKind } from './Shared'; + +export function getGuideTileId({ + kind, + title, + size = 'regular', +}: { + kind: ResourceKind; + title?: string; + size?: Size; +}) { + const base = `${size}-tile-${kind}`; + + return new RegExp(title ? `${base}-${title}` : base, 'i'); +} diff --git a/web/packages/teleport/src/User/UserContext.tsx b/web/packages/teleport/src/User/UserContext.tsx index 3dba8e941de8b..38cf0836226b6 100644 --- a/web/packages/teleport/src/User/UserContext.tsx +++ b/web/packages/teleport/src/User/UserContext.tsx @@ -32,6 +32,7 @@ import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpr import useAttempt from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; +import { DiscoverResourcePreference } from 'teleport/Discover/SelectResource/utils/pins'; import { StyledIndicator } from 'teleport/Main'; import { KeysEnum, storageService } from 'teleport/services/storageService'; import * as service from 'teleport/services/userPreferences'; @@ -39,6 +40,9 @@ import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/us export interface UserContextValue { preferences: UserPreferences; + updateDiscoverResourcePreferences: ( + preferences: Partial + ) => Promise; updatePreferences: (preferences: Partial) => Promise; updateClusterPinnedResources: ( clusterId: string, @@ -93,6 +97,20 @@ export function UserContextProvider(props: PropsWithChildren) { }); }; + const updateDiscoverResourcePreferences = async ( + discoverResourcePreferences: Partial + ) => { + const nextPreferences: UserPreferences = { + ...preferences, + ...discoverResourcePreferences, + }; + + return service.updateUserPreferences(nextPreferences).then(() => { + setPreferences(nextPreferences); + storageService.setUserPreferences(nextPreferences); + }); + }; + async function loadUserPreferences() { const storedPreferences = storageService.getUserPreferences(); @@ -132,6 +150,7 @@ export function UserContextProvider(props: PropsWithChildren) { ...newPreferences.accessGraph, }, } as UserPreferences; + setPreferences(nextPreferences); storageService.setUserPreferences(nextPreferences); @@ -171,6 +190,7 @@ export function UserContextProvider(props: PropsWithChildren) { updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {props.children} diff --git a/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts b/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts index 8b81a3e095c7e..fc60daed886bf 100644 --- a/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts +++ b/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts @@ -38,6 +38,7 @@ export const makeTestUserContext = ( updatePreferences: () => Promise.resolve(), updateClusterPinnedResources: () => Promise.resolve(), getClusterPinnedResources: () => Promise.resolve(), + updateDiscoverResourcePreferences: () => Promise.resolve(), }, overrides ); diff --git a/web/packages/teleport/src/services/userPreferences/discoverPreference.ts b/web/packages/teleport/src/services/userPreferences/discoverPreference.ts new file mode 100644 index 0000000000000..05c1b2ce273c2 --- /dev/null +++ b/web/packages/teleport/src/services/userPreferences/discoverPreference.ts @@ -0,0 +1,90 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Used to uniquely identify resource guides. These UID's will + * be stored in the backend as user preference to preserve + * which resource guides user wanted to "pin". + * + * There is no specific format to use, just ensure that enum values + * are unique. + * + * Existing enum values must not be modified. + */ +export enum DiscoverGuideId { + // Servers: + ServerLinuxUbuntu = 'server-linux-ubuntu', + ServerLinuxDebian = 'server-linux-debian', + ServerLinuxRhelCentos = 'server-linux-rhel-centos', + ServerLinuxAmazon = 'server-linux-amazon', + ServerMac = 'server-mac', + ServerAwsEc2Ssm = 'server-aws-ec2-ssm', + ConnectMyComputer = 'connect-my-computer', + + // Applications: + ApplicationWebHttpProxy = 'application-web-http-proxy', + ApplicationAwsCliConsole = 'application-aws-cli-console', + ApplicationSamlGeneric = 'application-saml-generic', + ApplicationSamlGrafana = 'application-saml-grafana', + ApplicationSamlWorkforceIdentityFederation = 'application-saml-workforce-identity-federation', + + // Windows Desktops: + WindowsDesktopsActiveDirectory = 'windows-desktops-active-directory', + WindowsDesktopsLocal = 'windows-desktops-local', + + // Kubernetes: + Kubernetes = 'kubernetes', + KubernetesAwsEks = 'kubernetes-aws-eks', + + // Databases: + DatabaseAwsDynamoDb = 'database-aws-dynamo-db', + DatabaseAwsElastiCacheMemoryDb = 'database-aws-elasticache-memorydb', + DatabaseAwsCassandraKeyspaces = 'database-aws-cassandra-keyspaces', + DatabaseAwsRedshiftServerless = 'database-aws-redshift-serverless', + DatabaseAwsSqlServerAd = 'database-aws-sql-server-ad', + DatabaseAwsPostgresRedshift = 'database-aws-postgres-redshift', + DatabaseAwsRdsPostgres = 'database-aws-rds-postgres', + DatabaseAwsRdsProxyPostgres = 'database-aws-rds-proxy-postgres', + DatabaseAwsRdsAuroraPostgres = 'database-aws-rds-aurora-postgres', + DatabaseAwsRdsProxySqlServer = 'database-aws-rds-proxy-sql-server', + DatabaseAwsRdsProxyMariaMySql = 'database-aws-rds-proxy-maria-mysql', + DatabaseAwsRdsAuroraMysql = 'database-aws-rds-aurora-mysql', + DatabaseAwsRdsMysqlMariaDb = 'database-aws-rds-mysql-mariadb', + + DatabaseHighAvailability = 'database-high-availability', + DatabaseDynamicRegistration = 'database-dynamic-registration', + + DatabaseAzureRedis = 'database-azure-redis', + DatabaseAzurePostgres = 'database-azure-postgres', + DatabaseAzureMysql = 'database-azure-mysql', + DatabaseAzureSqlServerAd = 'database-azure-sql-server-ad', + + DatabaseGcpMysqlCloudSql = 'database-gcp-mysql-cloud-sql', + DatabaseGcpPostgresCloudSql = 'database-gcp-postgres-cloud-sql', + + DatabaseMongoAtlas = 'database-mongo-atlas', + DatabaseCassandraScyllaDb = 'database-cassandra-scylladb', + DatabaseCockroachDb = 'database-cockroachdb', + DatabaseElasticSearch = 'database-elasticsearch', + DatabaseMongoDb = 'database-mongodb', + DatabaseRedis = 'database-redis', + DatabaseRedisCluster = 'database-redis-cluster', + DatabaseSnowflake = 'database-snowflake', + DatabasePostgres = 'database-postgres', + DatabaseMysql = 'database-mysql', +} diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts index b8a05ddb3ea76..0adfb5eb36f67 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts @@ -17,6 +17,7 @@ */ import { ClusterUserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/cluster_preferences_pb'; +import { DiscoverResourcePreferences } from 'gen-proto-ts/teleport/userpreferences/v1/discover_resource_preferences_pb'; import { OnboardUserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb'; import { SideNavDrawerMode } from 'gen-proto-ts/teleport/userpreferences/v1/sidenav_preferences_pb'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; @@ -43,6 +44,7 @@ export interface BackendUserPreferences { onboard?: OnboardUserPreferences; clusterPreferences?: BackendClusterUserPreferences; unifiedResourcePreferences?: UnifiedResourcePreferences; + discoverResourcePreferences?: DiscoverResourcePreferences; } export async function getUserPreferences(): Promise { @@ -99,6 +101,7 @@ export function makeDefaultUserPreferences(): UserPreferences { }, clusterPreferences: makeDefaultUserClusterPreferences(), sideNavDrawerMode: SideNavDrawerMode.COLLAPSED, + discoverResourcePreferences: {}, }; }