From a9d4b4d56f0af2b05bd52a3e7d6158dcfaa11a7d Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 09:36:04 +0200 Subject: [PATCH 01/23] feat: update proto files --- api/proto/API.md | 75 +++++++++ api/proto/user/entities/user.proto | 7 +- .../user/messages/user_svc/edit_request.proto | 12 ++ .../messages/user_svc/edit_response.proto | 11 ++ api/proto/user/user_service.proto | 3 + .../internal/proto/user/entities/user.pb.go | 40 ++++- .../user/messages/user_svc/edit_request.pb.go | 153 ++++++++++++++++++ .../messages/user_svc/edit_response.pb.go | 125 ++++++++++++++ .../internal/proto/user/user_service.pb.go | 53 +++--- .../proto/user/user_service_grpc.pb.go | 38 +++++ .../grpc/generated/user/entities/user.ts | 66 +++++++- .../user/messages/user_svc/edit_request.ts | 150 +++++++++++++++++ .../user/messages/user_svc/edit_response.ts | 94 +++++++++++ .../grpc/generated/user/user_service.ts | 12 ++ 14 files changed, 801 insertions(+), 38 deletions(-) create mode 100644 api/proto/user/messages/user_svc/edit_request.proto create mode 100644 api/proto/user/messages/user_svc/edit_response.proto create mode 100644 components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go create mode 100644 components/backend/internal/proto/user/messages/user_svc/edit_response.pb.go create mode 100644 components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts create mode 100644 components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_response.ts diff --git a/api/proto/API.md b/api/proto/API.md index 88d10f1c..631d7a61 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -483,6 +483,12 @@ - [user/messages/user_svc/add_role_response.proto](#user_messages_user_svc_add_role_response-proto) - [AddRoleResponse](#user-messages-user_svc-AddRoleResponse) +- [user/messages/user_svc/edit_request.proto](#user_messages_user_svc_edit_request-proto) + - [EditRequest](#user-messages-user_svc-EditRequest) + +- [user/messages/user_svc/edit_response.proto](#user_messages_user_svc_edit_response-proto) + - [EditResponse](#user-messages-user_svc-EditResponse) + - [user/messages/user_svc/get_request.proto](#user_messages_user_svc_get_request-proto) - [GetRequest](#user-messages-user_svc-GetRequest) @@ -787,6 +793,9 @@ See components/backend/internal/middleware/rbac.go. | created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | | display_name | [string](#string) | | | | email | [string](#string) | | | +| renku | [string](#string) | | | +| github | [string](#string) | | | +| linkedin | [string](#string) | | | | roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | | modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | @@ -5746,6 +5755,71 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## user/messages/user_svc/edit_request.proto + + + + + +### EditRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user_id | [string](#string) | | | +| renku | [string](#string) | optional | | +| github | [string](#string) | optional | | +| linkedin | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

+ +## user/messages/user_svc/edit_response.proto + + + + + +### EditResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| user | [user.entities.User](#user-entities-User) | | | + + + + + + + + + + + + + + +

Top

@@ -6064,6 +6138,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | ----------- | ------------ | ------------- | ------------| | List | [messages.user_svc.ListRequest](#user-messages-user_svc-ListRequest) | [messages.user_svc.ListResponse](#user-messages-user_svc-ListResponse) | | | Get | [messages.user_svc.GetRequest](#user-messages-user_svc-GetRequest) | [messages.user_svc.GetResponse](#user-messages-user_svc-GetResponse) | | +| Edit | [messages.user_svc.EditRequest](#user-messages-user_svc-EditRequest) | [messages.user_svc.EditResponse](#user-messages-user_svc-EditResponse) | | | WhoAmI | [messages.user_svc.WhoAmIRequest](#user-messages-user_svc-WhoAmIRequest) | [messages.user_svc.WhoAmIResponse](#user-messages-user_svc-WhoAmIResponse) | | | Register | [messages.user_svc.RegisterRequest](#user-messages-user_svc-RegisterRequest) | [messages.user_svc.RegisterResponse](#user-messages-user_svc-RegisterResponse) | | | AddRole | [messages.user_svc.AddRoleRequest](#user-messages-user_svc-AddRoleRequest) | [messages.user_svc.AddRoleResponse](#user-messages-user_svc-AddRoleResponse) | | diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index bfd08072..deefbf09 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -14,7 +14,10 @@ message User { google.protobuf.Timestamp created_at = 4; string display_name = 5; string email = 6; + string renku = 7; + string github = 8; + string linkedin = 9; // Populated from casbin on fetch; not persisted in ent DB. - repeated GlobalRole roles = 7; - google.protobuf.Timestamp modified_at = 8; + repeated GlobalRole roles = 10; + google.protobuf.Timestamp modified_at = 11; } diff --git a/api/proto/user/messages/user_svc/edit_request.proto b/api/proto/user/messages/user_svc/edit_request.proto new file mode 100644 index 00000000..7c60dbe4 --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_request.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +message EditRequest { + string user_id = 1; + optional string renku = 2; + optional string github = 3; + optional string linkedin = 4; +} diff --git a/api/proto/user/messages/user_svc/edit_response.proto b/api/proto/user/messages/user_svc/edit_response.proto new file mode 100644 index 00000000..8006cddd --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_response.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +import "user/entities/user.proto"; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +message EditResponse { + user.entities.User user = 1; +} diff --git a/api/proto/user/user_service.proto b/api/proto/user/user_service.proto index de4e204c..4cface1a 100644 --- a/api/proto/user/user_service.proto +++ b/api/proto/user/user_service.proto @@ -6,6 +6,8 @@ import "user/messages/user_svc/add_role_request.proto"; import "user/messages/user_svc/add_role_response.proto"; import "user/messages/user_svc/get_request.proto"; import "user/messages/user_svc/get_response.proto"; +import "user/messages/user_svc/edit_request.proto"; +import "user/messages/user_svc/edit_response.proto"; import "user/messages/user_svc/list_request.proto"; import "user/messages/user_svc/list_response.proto"; import "user/messages/user_svc/register_request.proto"; @@ -20,6 +22,7 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe service UserService { rpc List(user.messages.user_svc.ListRequest) returns (user.messages.user_svc.ListResponse); rpc Get(user.messages.user_svc.GetRequest) returns (user.messages.user_svc.GetResponse); + rpc Edit(user.messages.user_svc.EditRequest) returns (user.messages.user_svc.EditResponse); rpc WhoAmI(user.messages.user_svc.WhoAmIRequest) returns (user.messages.user_svc.WhoAmIResponse); rpc Register(user.messages.user_svc.RegisterRequest) returns (user.messages.user_svc.RegisterResponse); rpc AddRole(user.messages.user_svc.AddRoleRequest) returns (user.messages.user_svc.AddRoleResponse); diff --git a/components/backend/internal/proto/user/entities/user.pb.go b/components/backend/internal/proto/user/entities/user.pb.go index 7a09b687..ab2d3dac 100644 --- a/components/backend/internal/proto/user/entities/user.pb.go +++ b/components/backend/internal/proto/user/entities/user.pb.go @@ -30,9 +30,12 @@ type User struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` Email string `protobuf:"bytes,6,opt,name=email,proto3" json:"email,omitempty"` + Renku string `protobuf:"bytes,7,opt,name=renku,proto3" json:"renku,omitempty"` + Github string `protobuf:"bytes,8,opt,name=github,proto3" json:"github,omitempty"` + Linkedin string `protobuf:"bytes,9,opt,name=linkedin,proto3" json:"linkedin,omitempty"` // Populated from casbin on fetch; not persisted in ent DB. - Roles []GlobalRole `protobuf:"varint,7,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` - ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + Roles []GlobalRole `protobuf:"varint,10,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` + ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +112,27 @@ func (x *User) GetEmail() string { return "" } +func (x *User) GetRenku() string { + if x != nil { + return x.Renku + } + return "" +} + +func (x *User) GetGithub() string { + if x != nil { + return x.Github + } + return "" +} + +func (x *User) GetLinkedin() string { + if x != nil { + return x.Linkedin + } + return "" +} + func (x *User) GetRoles() []GlobalRole { if x != nil { return x.Roles @@ -127,7 +151,7 @@ var File_user_entities_user_proto protoreflect.FileDescriptor const file_user_entities_user_proto_rawDesc = "" + "\n" + - "\x18user/entities/user.proto\x12\ruser.entities\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fuser/entities/global_role.proto\"\xb5\x02\n" + + "\x18user/entities/user.proto\x12\ruser.entities\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fuser/entities/global_role.proto\"\xff\x02\n" + "\x04User\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x1f\n" + @@ -136,9 +160,13 @@ const file_user_entities_user_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" + "\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x06 \x01(\tR\x05email\x12/\n" + - "\x05roles\x18\a \x03(\x0e2\x19.user.entities.GlobalRoleR\x05roles\x12;\n" + - "\vmodified_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "\x05email\x18\x06 \x01(\tR\x05email\x12\x14\n" + + "\x05renku\x18\a \x01(\tR\x05renku\x12\x16\n" + + "\x06github\x18\b \x01(\tR\x06github\x12\x1a\n" + + "\blinkedin\x18\t \x01(\tR\blinkedin\x12/\n" + + "\x05roles\x18\n" + + " \x03(\x0e2\x19.user.entities.GlobalRoleR\x05roles\x12;\n" + + "\vmodified_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" + "modifiedAtB\\ZZgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entitiesb\x06proto3" var ( diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go new file mode 100644 index 00000000..42b92e0b --- /dev/null +++ b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go @@ -0,0 +1,153 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: user/messages/user_svc/edit_request.proto + +package user_svc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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) +) + +type EditRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Renku *string `protobuf:"bytes,2,opt,name=renku,proto3,oneof" json:"renku,omitempty"` + Github *string `protobuf:"bytes,3,opt,name=github,proto3,oneof" json:"github,omitempty"` + Linkedin *string `protobuf:"bytes,4,opt,name=linkedin,proto3,oneof" json:"linkedin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditRequest) Reset() { + *x = EditRequest{} + mi := &file_user_messages_user_svc_edit_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditRequest) ProtoMessage() {} + +func (x *EditRequest) ProtoReflect() protoreflect.Message { + mi := &file_user_messages_user_svc_edit_request_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 EditRequest.ProtoReflect.Descriptor instead. +func (*EditRequest) Descriptor() ([]byte, []int) { + return file_user_messages_user_svc_edit_request_proto_rawDescGZIP(), []int{0} +} + +func (x *EditRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *EditRequest) GetRenku() string { + if x != nil && x.Renku != nil { + return *x.Renku + } + return "" +} + +func (x *EditRequest) GetGithub() string { + if x != nil && x.Github != nil { + return *x.Github + } + return "" +} + +func (x *EditRequest) GetLinkedin() string { + if x != nil && x.Linkedin != nil { + return *x.Linkedin + } + return "" +} + +var File_user_messages_user_svc_edit_request_proto protoreflect.FileDescriptor + +const file_user_messages_user_svc_edit_request_proto_rawDesc = "" + + "\n" + + ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\"\xa1\x01\n" + + "\vEditRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x19\n" + + "\x05renku\x18\x02 \x01(\tH\x00R\x05renku\x88\x01\x01\x12\x1b\n" + + "\x06github\x18\x03 \x01(\tH\x01R\x06github\x88\x01\x01\x12\x1f\n" + + "\blinkedin\x18\x04 \x01(\tH\x02R\blinkedin\x88\x01\x01B\b\n" + + "\x06_renkuB\t\n" + + "\a_githubB\v\n" + + "\t_linkedinBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" + +var ( + file_user_messages_user_svc_edit_request_proto_rawDescOnce sync.Once + file_user_messages_user_svc_edit_request_proto_rawDescData []byte +) + +func file_user_messages_user_svc_edit_request_proto_rawDescGZIP() []byte { + file_user_messages_user_svc_edit_request_proto_rawDescOnce.Do(func() { + file_user_messages_user_svc_edit_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_request_proto_rawDesc), len(file_user_messages_user_svc_edit_request_proto_rawDesc))) + }) + return file_user_messages_user_svc_edit_request_proto_rawDescData +} + +var file_user_messages_user_svc_edit_request_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_user_messages_user_svc_edit_request_proto_goTypes = []any{ + (*EditRequest)(nil), // 0: user.messages.user_svc.EditRequest +} +var file_user_messages_user_svc_edit_request_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_user_messages_user_svc_edit_request_proto_init() } +func file_user_messages_user_svc_edit_request_proto_init() { + if File_user_messages_user_svc_edit_request_proto != nil { + return + } + file_user_messages_user_svc_edit_request_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_request_proto_rawDesc), len(file_user_messages_user_svc_edit_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_user_messages_user_svc_edit_request_proto_goTypes, + DependencyIndexes: file_user_messages_user_svc_edit_request_proto_depIdxs, + MessageInfos: file_user_messages_user_svc_edit_request_proto_msgTypes, + }.Build() + File_user_messages_user_svc_edit_request_proto = out.File + file_user_messages_user_svc_edit_request_proto_goTypes = nil + file_user_messages_user_svc_edit_request_proto_depIdxs = nil +} diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_response.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_response.pb.go new file mode 100644 index 00000000..eaf7ab08 --- /dev/null +++ b/components/backend/internal/proto/user/messages/user_svc/edit_response.pb.go @@ -0,0 +1,125 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: user/messages/user_svc/edit_response.proto + +package user_svc + +import ( + entities "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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) +) + +type EditResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *entities.User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditResponse) Reset() { + *x = EditResponse{} + mi := &file_user_messages_user_svc_edit_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditResponse) ProtoMessage() {} + +func (x *EditResponse) ProtoReflect() protoreflect.Message { + mi := &file_user_messages_user_svc_edit_response_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 EditResponse.ProtoReflect.Descriptor instead. +func (*EditResponse) Descriptor() ([]byte, []int) { + return file_user_messages_user_svc_edit_response_proto_rawDescGZIP(), []int{0} +} + +func (x *EditResponse) GetUser() *entities.User { + if x != nil { + return x.User + } + return nil +} + +var File_user_messages_user_svc_edit_response_proto protoreflect.FileDescriptor + +const file_user_messages_user_svc_edit_response_proto_rawDesc = "" + + "\n" + + "*user/messages/user_svc/edit_response.proto\x12\x16user.messages.user_svc\x1a\x18user/entities/user.proto\"7\n" + + "\fEditResponse\x12'\n" + + "\x04user\x18\x01 \x01(\v2\x13.user.entities.UserR\x04userBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" + +var ( + file_user_messages_user_svc_edit_response_proto_rawDescOnce sync.Once + file_user_messages_user_svc_edit_response_proto_rawDescData []byte +) + +func file_user_messages_user_svc_edit_response_proto_rawDescGZIP() []byte { + file_user_messages_user_svc_edit_response_proto_rawDescOnce.Do(func() { + file_user_messages_user_svc_edit_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_response_proto_rawDesc), len(file_user_messages_user_svc_edit_response_proto_rawDesc))) + }) + return file_user_messages_user_svc_edit_response_proto_rawDescData +} + +var file_user_messages_user_svc_edit_response_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_user_messages_user_svc_edit_response_proto_goTypes = []any{ + (*EditResponse)(nil), // 0: user.messages.user_svc.EditResponse + (*entities.User)(nil), // 1: user.entities.User +} +var file_user_messages_user_svc_edit_response_proto_depIdxs = []int32{ + 1, // 0: user.messages.user_svc.EditResponse.user:type_name -> user.entities.User + 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_user_messages_user_svc_edit_response_proto_init() } +func file_user_messages_user_svc_edit_response_proto_init() { + if File_user_messages_user_svc_edit_response_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_response_proto_rawDesc), len(file_user_messages_user_svc_edit_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_user_messages_user_svc_edit_response_proto_goTypes, + DependencyIndexes: file_user_messages_user_svc_edit_response_proto_depIdxs, + MessageInfos: file_user_messages_user_svc_edit_response_proto_msgTypes, + }.Build() + File_user_messages_user_svc_edit_response_proto = out.File + file_user_messages_user_svc_edit_response_proto_goTypes = nil + file_user_messages_user_svc_edit_response_proto_depIdxs = nil +} diff --git a/components/backend/internal/proto/user/user_service.pb.go b/components/backend/internal/proto/user/user_service.pb.go index ab499110..6d22714e 100644 --- a/components/backend/internal/proto/user/user_service.pb.go +++ b/components/backend/internal/proto/user/user_service.pb.go @@ -25,10 +25,11 @@ var File_user_user_service_proto protoreflect.FileDescriptor const file_user_user_service_proto_rawDesc = "" + "\n" + - "\x17user/user_service.proto\x12\x04user\x1a-user/messages/user_svc/add_role_request.proto\x1a.user/messages/user_svc/add_role_response.proto\x1a(user/messages/user_svc/get_request.proto\x1a)user/messages/user_svc/get_response.proto\x1a)user/messages/user_svc/list_request.proto\x1a*user/messages/user_svc/list_response.proto\x1a-user/messages/user_svc/register_request.proto\x1a.user/messages/user_svc/register_response.proto\x1a0user/messages/user_svc/remove_role_request.proto\x1a1user/messages/user_svc/remove_role_response.proto\x1a-user/messages/user_svc/who_am_i_request.proto\x1a.user/messages/user_svc/who_am_i_response.proto2\xa9\x04\n" + + "\x17user/user_service.proto\x12\x04user\x1a-user/messages/user_svc/add_role_request.proto\x1a.user/messages/user_svc/add_role_response.proto\x1a(user/messages/user_svc/get_request.proto\x1a)user/messages/user_svc/get_response.proto\x1a)user/messages/user_svc/edit_request.proto\x1a*user/messages/user_svc/edit_response.proto\x1a)user/messages/user_svc/list_request.proto\x1a*user/messages/user_svc/list_response.proto\x1a-user/messages/user_svc/register_request.proto\x1a.user/messages/user_svc/register_response.proto\x1a0user/messages/user_svc/remove_role_request.proto\x1a1user/messages/user_svc/remove_role_response.proto\x1a-user/messages/user_svc/who_am_i_request.proto\x1a.user/messages/user_svc/who_am_i_response.proto2\xfc\x04\n" + "\vUserService\x12Q\n" + "\x04List\x12#.user.messages.user_svc.ListRequest\x1a$.user.messages.user_svc.ListResponse\x12N\n" + - "\x03Get\x12\".user.messages.user_svc.GetRequest\x1a#.user.messages.user_svc.GetResponse\x12W\n" + + "\x03Get\x12\".user.messages.user_svc.GetRequest\x1a#.user.messages.user_svc.GetResponse\x12Q\n" + + "\x04Edit\x12#.user.messages.user_svc.EditRequest\x1a$.user.messages.user_svc.EditResponse\x12W\n" + "\x06WhoAmI\x12%.user.messages.user_svc.WhoAmIRequest\x1a&.user.messages.user_svc.WhoAmIResponse\x12]\n" + "\bRegister\x12'.user.messages.user_svc.RegisterRequest\x1a(.user.messages.user_svc.RegisterResponse\x12Z\n" + "\aAddRole\x12&.user.messages.user_svc.AddRoleRequest\x1a'.user.messages.user_svc.AddRoleResponse\x12c\n" + @@ -38,32 +39,36 @@ const file_user_user_service_proto_rawDesc = "" + var file_user_user_service_proto_goTypes = []any{ (*user_svc.ListRequest)(nil), // 0: user.messages.user_svc.ListRequest (*user_svc.GetRequest)(nil), // 1: user.messages.user_svc.GetRequest - (*user_svc.WhoAmIRequest)(nil), // 2: user.messages.user_svc.WhoAmIRequest - (*user_svc.RegisterRequest)(nil), // 3: user.messages.user_svc.RegisterRequest - (*user_svc.AddRoleRequest)(nil), // 4: user.messages.user_svc.AddRoleRequest - (*user_svc.RemoveRoleRequest)(nil), // 5: user.messages.user_svc.RemoveRoleRequest - (*user_svc.ListResponse)(nil), // 6: user.messages.user_svc.ListResponse - (*user_svc.GetResponse)(nil), // 7: user.messages.user_svc.GetResponse - (*user_svc.WhoAmIResponse)(nil), // 8: user.messages.user_svc.WhoAmIResponse - (*user_svc.RegisterResponse)(nil), // 9: user.messages.user_svc.RegisterResponse - (*user_svc.AddRoleResponse)(nil), // 10: user.messages.user_svc.AddRoleResponse - (*user_svc.RemoveRoleResponse)(nil), // 11: user.messages.user_svc.RemoveRoleResponse + (*user_svc.EditRequest)(nil), // 2: user.messages.user_svc.EditRequest + (*user_svc.WhoAmIRequest)(nil), // 3: user.messages.user_svc.WhoAmIRequest + (*user_svc.RegisterRequest)(nil), // 4: user.messages.user_svc.RegisterRequest + (*user_svc.AddRoleRequest)(nil), // 5: user.messages.user_svc.AddRoleRequest + (*user_svc.RemoveRoleRequest)(nil), // 6: user.messages.user_svc.RemoveRoleRequest + (*user_svc.ListResponse)(nil), // 7: user.messages.user_svc.ListResponse + (*user_svc.GetResponse)(nil), // 8: user.messages.user_svc.GetResponse + (*user_svc.EditResponse)(nil), // 9: user.messages.user_svc.EditResponse + (*user_svc.WhoAmIResponse)(nil), // 10: user.messages.user_svc.WhoAmIResponse + (*user_svc.RegisterResponse)(nil), // 11: user.messages.user_svc.RegisterResponse + (*user_svc.AddRoleResponse)(nil), // 12: user.messages.user_svc.AddRoleResponse + (*user_svc.RemoveRoleResponse)(nil), // 13: user.messages.user_svc.RemoveRoleResponse } var file_user_user_service_proto_depIdxs = []int32{ 0, // 0: user.UserService.List:input_type -> user.messages.user_svc.ListRequest 1, // 1: user.UserService.Get:input_type -> user.messages.user_svc.GetRequest - 2, // 2: user.UserService.WhoAmI:input_type -> user.messages.user_svc.WhoAmIRequest - 3, // 3: user.UserService.Register:input_type -> user.messages.user_svc.RegisterRequest - 4, // 4: user.UserService.AddRole:input_type -> user.messages.user_svc.AddRoleRequest - 5, // 5: user.UserService.RemoveRole:input_type -> user.messages.user_svc.RemoveRoleRequest - 6, // 6: user.UserService.List:output_type -> user.messages.user_svc.ListResponse - 7, // 7: user.UserService.Get:output_type -> user.messages.user_svc.GetResponse - 8, // 8: user.UserService.WhoAmI:output_type -> user.messages.user_svc.WhoAmIResponse - 9, // 9: user.UserService.Register:output_type -> user.messages.user_svc.RegisterResponse - 10, // 10: user.UserService.AddRole:output_type -> user.messages.user_svc.AddRoleResponse - 11, // 11: user.UserService.RemoveRole:output_type -> user.messages.user_svc.RemoveRoleResponse - 6, // [6:12] is the sub-list for method output_type - 0, // [0:6] is the sub-list for method input_type + 2, // 2: user.UserService.Edit:input_type -> user.messages.user_svc.EditRequest + 3, // 3: user.UserService.WhoAmI:input_type -> user.messages.user_svc.WhoAmIRequest + 4, // 4: user.UserService.Register:input_type -> user.messages.user_svc.RegisterRequest + 5, // 5: user.UserService.AddRole:input_type -> user.messages.user_svc.AddRoleRequest + 6, // 6: user.UserService.RemoveRole:input_type -> user.messages.user_svc.RemoveRoleRequest + 7, // 7: user.UserService.List:output_type -> user.messages.user_svc.ListResponse + 8, // 8: user.UserService.Get:output_type -> user.messages.user_svc.GetResponse + 9, // 9: user.UserService.Edit:output_type -> user.messages.user_svc.EditResponse + 10, // 10: user.UserService.WhoAmI:output_type -> user.messages.user_svc.WhoAmIResponse + 11, // 11: user.UserService.Register:output_type -> user.messages.user_svc.RegisterResponse + 12, // 12: user.UserService.AddRole:output_type -> user.messages.user_svc.AddRoleResponse + 13, // 13: user.UserService.RemoveRole:output_type -> user.messages.user_svc.RemoveRoleResponse + 7, // [7:14] is the sub-list for method output_type + 0, // [0:7] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/components/backend/internal/proto/user/user_service_grpc.pb.go b/components/backend/internal/proto/user/user_service_grpc.pb.go index b96fb214..4b6375e1 100644 --- a/components/backend/internal/proto/user/user_service_grpc.pb.go +++ b/components/backend/internal/proto/user/user_service_grpc.pb.go @@ -22,6 +22,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( UserService_List_FullMethodName = "/user.UserService/List" UserService_Get_FullMethodName = "/user.UserService/Get" + UserService_Edit_FullMethodName = "/user.UserService/Edit" UserService_WhoAmI_FullMethodName = "/user.UserService/WhoAmI" UserService_Register_FullMethodName = "/user.UserService/Register" UserService_AddRole_FullMethodName = "/user.UserService/AddRole" @@ -34,6 +35,7 @@ const ( type UserServiceClient interface { List(ctx context.Context, in *user_svc.ListRequest, opts ...grpc.CallOption) (*user_svc.ListResponse, error) Get(ctx context.Context, in *user_svc.GetRequest, opts ...grpc.CallOption) (*user_svc.GetResponse, error) + Edit(ctx context.Context, in *user_svc.EditRequest, opts ...grpc.CallOption) (*user_svc.EditResponse, error) WhoAmI(ctx context.Context, in *user_svc.WhoAmIRequest, opts ...grpc.CallOption) (*user_svc.WhoAmIResponse, error) Register(ctx context.Context, in *user_svc.RegisterRequest, opts ...grpc.CallOption) (*user_svc.RegisterResponse, error) AddRole(ctx context.Context, in *user_svc.AddRoleRequest, opts ...grpc.CallOption) (*user_svc.AddRoleResponse, error) @@ -68,6 +70,16 @@ func (c *userServiceClient) Get(ctx context.Context, in *user_svc.GetRequest, op return out, nil } +func (c *userServiceClient) Edit(ctx context.Context, in *user_svc.EditRequest, opts ...grpc.CallOption) (*user_svc.EditResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(user_svc.EditResponse) + err := c.cc.Invoke(ctx, UserService_Edit_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *userServiceClient) WhoAmI(ctx context.Context, in *user_svc.WhoAmIRequest, opts ...grpc.CallOption) (*user_svc.WhoAmIResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(user_svc.WhoAmIResponse) @@ -114,6 +126,7 @@ func (c *userServiceClient) RemoveRole(ctx context.Context, in *user_svc.RemoveR type UserServiceServer interface { List(context.Context, *user_svc.ListRequest) (*user_svc.ListResponse, error) Get(context.Context, *user_svc.GetRequest) (*user_svc.GetResponse, error) + Edit(context.Context, *user_svc.EditRequest) (*user_svc.EditResponse, error) WhoAmI(context.Context, *user_svc.WhoAmIRequest) (*user_svc.WhoAmIResponse, error) Register(context.Context, *user_svc.RegisterRequest) (*user_svc.RegisterResponse, error) AddRole(context.Context, *user_svc.AddRoleRequest) (*user_svc.AddRoleResponse, error) @@ -134,6 +147,9 @@ func (UnimplementedUserServiceServer) List(context.Context, *user_svc.ListReques func (UnimplementedUserServiceServer) Get(context.Context, *user_svc.GetRequest) (*user_svc.GetResponse, error) { return nil, status.Error(codes.Unimplemented, "method Get not implemented") } +func (UnimplementedUserServiceServer) Edit(context.Context, *user_svc.EditRequest) (*user_svc.EditResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Edit not implemented") +} func (UnimplementedUserServiceServer) WhoAmI(context.Context, *user_svc.WhoAmIRequest) (*user_svc.WhoAmIResponse, error) { return nil, status.Error(codes.Unimplemented, "method WhoAmI not implemented") } @@ -203,6 +219,24 @@ func _UserService_Get_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _UserService_Edit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(user_svc.EditRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).Edit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_Edit_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).Edit(ctx, req.(*user_svc.EditRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _UserService_WhoAmI_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(user_svc.WhoAmIRequest) if err := dec(in); err != nil { @@ -290,6 +324,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Get", Handler: _UserService_Get_Handler, }, + { + MethodName: "Edit", + Handler: _UserService_Edit_Handler, + }, { MethodName: "WhoAmI", Handler: _UserService_WhoAmI_Handler, diff --git a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts index 48f41e1a..90d7e830 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts @@ -18,6 +18,9 @@ export interface User { createdAt: Date | undefined; displayName: string; email: string; + renku: string; + github: string; + linkedin: string; /** Populated from casbin on fetch; not persisted in ent DB. */ roles: GlobalRole[]; modifiedAt: Date | undefined; @@ -31,6 +34,9 @@ function createBaseUser(): User { createdAt: undefined, displayName: "", email: "", + renku: "", + github: "", + linkedin: "", roles: [], modifiedAt: undefined, }; @@ -56,13 +62,22 @@ export const User: MessageFns = { if (message.email !== "") { writer.uint32(50).string(message.email); } - writer.uint32(58).fork(); + if (message.renku !== "") { + writer.uint32(58).string(message.renku); + } + if (message.github !== "") { + writer.uint32(66).string(message.github); + } + if (message.linkedin !== "") { + writer.uint32(74).string(message.linkedin); + } + writer.uint32(82).fork(); for (const v of message.roles) { writer.int32(v); } writer.join(); if (message.modifiedAt !== undefined) { - Timestamp.encode(toTimestamp(message.modifiedAt), writer.uint32(66).fork()).join(); + Timestamp.encode(toTimestamp(message.modifiedAt), writer.uint32(90).fork()).join(); } return writer; }, @@ -123,13 +138,37 @@ export const User: MessageFns = { continue; } case 7: { - if (tag === 56) { + if (tag !== 58) { + break; + } + + message.renku = reader.string(); + continue; + } + case 8: { + if (tag !== 66) { + break; + } + + message.github = reader.string(); + continue; + } + case 9: { + if (tag !== 74) { + break; + } + + message.linkedin = reader.string(); + continue; + } + case 10: { + if (tag === 80) { message.roles.push(reader.int32() as any); continue; } - if (tag === 58) { + if (tag === 82) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.roles.push(reader.int32() as any); @@ -140,8 +179,8 @@ export const User: MessageFns = { break; } - case 8: { - if (tag !== 66) { + case 11: { + if (tag !== 90) { break; } @@ -177,6 +216,9 @@ export const User: MessageFns = { ? globalThis.String(object.display_name) : "", email: isSet(object.email) ? globalThis.String(object.email) : "", + renku: isSet(object.renku) ? globalThis.String(object.renku) : "", + github: isSet(object.github) ? globalThis.String(object.github) : "", + linkedin: isSet(object.linkedin) ? globalThis.String(object.linkedin) : "", roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalRoleFromJSON(e)) : [], modifiedAt: isSet(object.modifiedAt) ? fromJsonTimestamp(object.modifiedAt) @@ -206,6 +248,15 @@ export const User: MessageFns = { if (message.email !== "") { obj.email = message.email; } + if (message.renku !== "") { + obj.renku = message.renku; + } + if (message.github !== "") { + obj.github = message.github; + } + if (message.linkedin !== "") { + obj.linkedin = message.linkedin; + } if (message.roles?.length) { obj.roles = message.roles.map((e) => globalRoleToJSON(e)); } @@ -226,6 +277,9 @@ export const User: MessageFns = { message.createdAt = object.createdAt ?? undefined; message.displayName = object.displayName ?? ""; message.email = object.email ?? ""; + message.renku = object.renku ?? ""; + message.github = object.github ?? ""; + message.linkedin = object.linkedin ?? ""; message.roles = object.roles?.map((e) => e) || []; message.modifiedAt = object.modifiedAt ?? undefined; return message; diff --git a/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts new file mode 100644 index 00000000..190ce744 --- /dev/null +++ b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts @@ -0,0 +1,150 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.6 +// protoc unknown +// source: user/messages/user_svc/edit_request.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "user.messages.user_svc"; + +export interface EditRequest { + userId: string; + renku?: string | undefined; + github?: string | undefined; + linkedin?: string | undefined; +} + +function createBaseEditRequest(): EditRequest { + return { userId: "", renku: undefined, github: undefined, linkedin: undefined }; +} + +export const EditRequest: MessageFns = { + encode(message: EditRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.userId !== "") { + writer.uint32(10).string(message.userId); + } + if (message.renku !== undefined) { + writer.uint32(18).string(message.renku); + } + if (message.github !== undefined) { + writer.uint32(26).string(message.github); + } + if (message.linkedin !== undefined) { + writer.uint32(34).string(message.linkedin); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EditRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEditRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.userId = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.renku = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.github = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.linkedin = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EditRequest { + return { + userId: isSet(object.userId) + ? globalThis.String(object.userId) + : isSet(object.user_id) + ? globalThis.String(object.user_id) + : "", + renku: isSet(object.renku) ? globalThis.String(object.renku) : undefined, + github: isSet(object.github) ? globalThis.String(object.github) : undefined, + linkedin: isSet(object.linkedin) ? globalThis.String(object.linkedin) : undefined, + }; + }, + + toJSON(message: EditRequest): unknown { + const obj: any = {}; + if (message.userId !== "") { + obj.userId = message.userId; + } + if (message.renku !== undefined) { + obj.renku = message.renku; + } + if (message.github !== undefined) { + obj.github = message.github; + } + if (message.linkedin !== undefined) { + obj.linkedin = message.linkedin; + } + return obj; + }, + + create(base?: DeepPartial): EditRequest { + return EditRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): EditRequest { + const message = createBaseEditRequest(); + message.userId = object.userId ?? ""; + message.renku = object.renku ?? undefined; + message.github = object.github ?? undefined; + message.linkedin = object.linkedin ?? undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create(base?: DeepPartial): T; + fromPartial(object: DeepPartial): T; +} diff --git a/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_response.ts b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_response.ts new file mode 100644 index 00000000..0ce5bba6 --- /dev/null +++ b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_response.ts @@ -0,0 +1,94 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.6 +// protoc unknown +// source: user/messages/user_svc/edit_response.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { User } from "../../entities/user"; + +export const protobufPackage = "user.messages.user_svc"; + +export interface EditResponse { + user: User | undefined; +} + +function createBaseEditResponse(): EditResponse { + return { user: undefined }; +} + +export const EditResponse: MessageFns = { + encode(message: EditResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.user !== undefined) { + User.encode(message.user, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EditResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEditResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.user = User.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EditResponse { + return { user: isSet(object.user) ? User.fromJSON(object.user) : undefined }; + }, + + toJSON(message: EditResponse): unknown { + const obj: any = {}; + if (message.user !== undefined) { + obj.user = User.toJSON(message.user); + } + return obj; + }, + + create(base?: DeepPartial): EditResponse { + return EditResponse.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): EditResponse { + const message = createBaseEditResponse(); + message.user = (object.user !== undefined && object.user !== null) ? User.fromPartial(object.user) : undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create(base?: DeepPartial): T; + fromPartial(object: DeepPartial): T; +} diff --git a/components/frontend/src/lib/server/grpc/generated/user/user_service.ts b/components/frontend/src/lib/server/grpc/generated/user/user_service.ts index 6358e763..d3313ede 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/user_service.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/user_service.ts @@ -8,6 +8,8 @@ import type { CallContext, CallOptions } from "nice-grpc-common"; import { AddRoleRequest } from "./messages/user_svc/add_role_request"; import { AddRoleResponse } from "./messages/user_svc/add_role_response"; +import { EditRequest } from "./messages/user_svc/edit_request"; +import { EditResponse } from "./messages/user_svc/edit_response"; import { GetRequest } from "./messages/user_svc/get_request"; import { GetResponse } from "./messages/user_svc/get_response"; import { ListRequest } from "./messages/user_svc/list_request"; @@ -42,6 +44,14 @@ export const UserServiceDefinition = { responseStream: false, options: {}, }, + edit: { + name: "Edit", + requestType: EditRequest as typeof EditRequest, + requestStream: false, + responseType: EditResponse as typeof EditResponse, + responseStream: false, + options: {}, + }, whoAmI: { name: "WhoAmI", requestType: WhoAmIRequest as typeof WhoAmIRequest, @@ -80,6 +90,7 @@ export const UserServiceDefinition = { export interface UserServiceImplementation { list(request: ListRequest, context: CallContext & CallContextExt): Promise>; get(request: GetRequest, context: CallContext & CallContextExt): Promise>; + edit(request: EditRequest, context: CallContext & CallContextExt): Promise>; whoAmI(request: WhoAmIRequest, context: CallContext & CallContextExt): Promise>; register(request: RegisterRequest, context: CallContext & CallContextExt): Promise>; addRole(request: AddRoleRequest, context: CallContext & CallContextExt): Promise>; @@ -92,6 +103,7 @@ export interface UserServiceImplementation { export interface UserServiceClient { list(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; get(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; + edit(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; whoAmI(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; register(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; addRole(request: DeepPartial, options?: CallOptions & CallOptionsExt): Promise; From 4ef86dd31e2be75b9c9dcbc34eb02bd830ed46d7 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 10:43:37 +0200 Subject: [PATCH 02/23] feat: add user links to db schema; add edituser service --- components/backend/Schema.md | 3 + components/backend/db/schema/user.go | 6 + components/backend/ent/migrate/schema.go | 3 + components/backend/ent/mutation.go | 221 +++++++++++++++- components/backend/ent/runtime/runtime.go | 16 +- components/backend/ent/user.go | 35 ++- components/backend/ent/user/user.go | 30 +++ components/backend/ent/user/where.go | 240 +++++++++++++++++ components/backend/ent/user_create.go | 246 ++++++++++++++++++ components/backend/ent/user_update.go | 156 +++++++++++ .../backend/internal/service/mappers.go | 3 + .../backend/internal/service/user_service.go | 49 ++++ .../backend/internal/service/userlinks.go | 113 ++++++++ 13 files changed, 1117 insertions(+), 4 deletions(-) create mode 100644 components/backend/internal/service/userlinks.go diff --git a/components/backend/Schema.md b/components/backend/Schema.md index adc2f85d..d3942900 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -394,6 +394,9 @@ An authenticated user, synced from Keycloak on first login. | `keycloak_id` | string | yes | yes | no | no | Unique identifier from Keycloak (sub claim). | | `display_name` | string | no | no | no | yes | Preferred display name of the user. | | `email` | string | no | no | no | yes | Email of the user, same as in Keycloak | +| `renku` | string | no | no | no | yes | Renku account of the user. | +| `github` | string | no | no | no | yes | Github account of the user. | +| `linkedin` | string | no | no | no | yes | LinkedIn account of the user. | | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the user was first seen. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last profile update. | diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index 80ced4ff..1be94629 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -32,6 +32,12 @@ func (User) Fields() []ent.Field { Comment("Preferred display name of the user."), field.String("email").Optional().Default(""). Comment("Email of the user, same as in Keycloak"), + field.String("renku").Optional().Default(""). + Comment("Renku account of the user."), + field.String("github").Optional().Default(""). + Comment("Github account of the user."), + field.String("linkedin").Optional().Default(""). + Comment("LinkedIn account of the user."), field.Time("created_at").Immutable().Default(time.Now). Comment("Timestamp when the user was first seen."), field.Time("modified_at"). diff --git a/components/backend/ent/migrate/schema.go b/components/backend/ent/migrate/schema.go index 09d1dbb4..045d768f 100644 --- a/components/backend/ent/migrate/schema.go +++ b/components/backend/ent/migrate/schema.go @@ -635,6 +635,9 @@ var ( {Name: "keycloak_id", Type: field.TypeString, Unique: true}, {Name: "display_name", Type: field.TypeString, Nullable: true, Default: ""}, {Name: "email", Type: field.TypeString, Nullable: true, Default: ""}, + {Name: "renku", Type: field.TypeString, Nullable: true, Default: ""}, + {Name: "github", Type: field.TypeString, Nullable: true, Default: ""}, + {Name: "linkedin", Type: field.TypeString, Nullable: true, Default: ""}, {Name: "created_at", Type: field.TypeTime}, {Name: "modified_at", Type: field.TypeTime}, } diff --git a/components/backend/ent/mutation.go b/components/backend/ent/mutation.go index 6acf257e..f8ba1589 100644 --- a/components/backend/ent/mutation.go +++ b/components/backend/ent/mutation.go @@ -12483,6 +12483,9 @@ type UserMutation struct { keycloak_id *string display_name *string email *string + renku *string + github *string + linkedin *string created_at *time.Time modified_at *time.Time clearedFields map[string]struct{} @@ -12843,6 +12846,153 @@ func (m *UserMutation) ResetEmail() { delete(m.clearedFields, user.FieldEmail) } +// SetRenku sets the "renku" field. +func (m *UserMutation) SetRenku(s string) { + m.renku = &s +} + +// Renku returns the value of the "renku" field in the mutation. +func (m *UserMutation) Renku() (r string, exists bool) { + v := m.renku + if v == nil { + return + } + return *v, true +} + +// OldRenku returns the old "renku" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldRenku(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldRenku is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldRenku requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldRenku: %w", err) + } + return oldValue.Renku, nil +} + +// ClearRenku clears the value of the "renku" field. +func (m *UserMutation) ClearRenku() { + m.renku = nil + m.clearedFields[user.FieldRenku] = struct{}{} +} + +// RenkuCleared returns if the "renku" field was cleared in this mutation. +func (m *UserMutation) RenkuCleared() bool { + _, ok := m.clearedFields[user.FieldRenku] + return ok +} + +// ResetRenku resets all changes to the "renku" field. +func (m *UserMutation) ResetRenku() { + m.renku = nil + delete(m.clearedFields, user.FieldRenku) +} + +// SetGithub sets the "github" field. +func (m *UserMutation) SetGithub(s string) { + m.github = &s +} + +// Github returns the value of the "github" field in the mutation. +func (m *UserMutation) Github() (r string, exists bool) { + v := m.github + if v == nil { + return + } + return *v, true +} + +// OldGithub returns the old "github" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldGithub(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldGithub is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldGithub requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldGithub: %w", err) + } + return oldValue.Github, nil +} + +// ClearGithub clears the value of the "github" field. +func (m *UserMutation) ClearGithub() { + m.github = nil + m.clearedFields[user.FieldGithub] = struct{}{} +} + +// GithubCleared returns if the "github" field was cleared in this mutation. +func (m *UserMutation) GithubCleared() bool { + _, ok := m.clearedFields[user.FieldGithub] + return ok +} + +// ResetGithub resets all changes to the "github" field. +func (m *UserMutation) ResetGithub() { + m.github = nil + delete(m.clearedFields, user.FieldGithub) +} + +// SetLinkedin sets the "linkedin" field. +func (m *UserMutation) SetLinkedin(s string) { + m.linkedin = &s +} + +// Linkedin returns the value of the "linkedin" field in the mutation. +func (m *UserMutation) Linkedin() (r string, exists bool) { + v := m.linkedin + if v == nil { + return + } + return *v, true +} + +// OldLinkedin returns the old "linkedin" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldLinkedin(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLinkedin is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLinkedin requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLinkedin: %w", err) + } + return oldValue.Linkedin, nil +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (m *UserMutation) ClearLinkedin() { + m.linkedin = nil + m.clearedFields[user.FieldLinkedin] = struct{}{} +} + +// LinkedinCleared returns if the "linkedin" field was cleared in this mutation. +func (m *UserMutation) LinkedinCleared() bool { + _, ok := m.clearedFields[user.FieldLinkedin] + return ok +} + +// ResetLinkedin resets all changes to the "linkedin" field. +func (m *UserMutation) ResetLinkedin() { + m.linkedin = nil + delete(m.clearedFields, user.FieldLinkedin) +} + // SetCreatedAt sets the "created_at" field. func (m *UserMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -14353,7 +14503,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 6) + fields := make([]string, 0, 9) if m.username != nil { fields = append(fields, user.FieldUsername) } @@ -14366,6 +14516,15 @@ func (m *UserMutation) Fields() []string { if m.email != nil { fields = append(fields, user.FieldEmail) } + if m.renku != nil { + fields = append(fields, user.FieldRenku) + } + if m.github != nil { + fields = append(fields, user.FieldGithub) + } + if m.linkedin != nil { + fields = append(fields, user.FieldLinkedin) + } if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -14388,6 +14547,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.DisplayName() case user.FieldEmail: return m.Email() + case user.FieldRenku: + return m.Renku() + case user.FieldGithub: + return m.Github() + case user.FieldLinkedin: + return m.Linkedin() case user.FieldCreatedAt: return m.CreatedAt() case user.FieldModifiedAt: @@ -14409,6 +14574,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldDisplayName(ctx) case user.FieldEmail: return m.OldEmail(ctx) + case user.FieldRenku: + return m.OldRenku(ctx) + case user.FieldGithub: + return m.OldGithub(ctx) + case user.FieldLinkedin: + return m.OldLinkedin(ctx) case user.FieldCreatedAt: return m.OldCreatedAt(ctx) case user.FieldModifiedAt: @@ -14450,6 +14621,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetEmail(v) return nil + case user.FieldRenku: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetRenku(v) + return nil + case user.FieldGithub: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetGithub(v) + return nil + case user.FieldLinkedin: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLinkedin(v) + return nil case user.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -14500,6 +14692,15 @@ func (m *UserMutation) ClearedFields() []string { if m.FieldCleared(user.FieldEmail) { fields = append(fields, user.FieldEmail) } + if m.FieldCleared(user.FieldRenku) { + fields = append(fields, user.FieldRenku) + } + if m.FieldCleared(user.FieldGithub) { + fields = append(fields, user.FieldGithub) + } + if m.FieldCleared(user.FieldLinkedin) { + fields = append(fields, user.FieldLinkedin) + } return fields } @@ -14520,6 +14721,15 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldEmail: m.ClearEmail() return nil + case user.FieldRenku: + m.ClearRenku() + return nil + case user.FieldGithub: + m.ClearGithub() + return nil + case user.FieldLinkedin: + m.ClearLinkedin() + return nil } return fmt.Errorf("unknown User nullable field %s", name) } @@ -14540,6 +14750,15 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldEmail: m.ResetEmail() return nil + case user.FieldRenku: + m.ResetRenku() + return nil + case user.FieldGithub: + m.ResetGithub() + return nil + case user.FieldLinkedin: + m.ResetLinkedin() + return nil case user.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/components/backend/ent/runtime/runtime.go b/components/backend/ent/runtime/runtime.go index 594b5a6a..f2a3ea03 100644 --- a/components/backend/ent/runtime/runtime.go +++ b/components/backend/ent/runtime/runtime.go @@ -364,12 +364,24 @@ func init() { userDescEmail := userFields[3].Descriptor() // user.DefaultEmail holds the default value on creation for the email field. user.DefaultEmail = userDescEmail.Default.(string) + // userDescRenku is the schema descriptor for renku field. + userDescRenku := userFields[4].Descriptor() + // user.DefaultRenku holds the default value on creation for the renku field. + user.DefaultRenku = userDescRenku.Default.(string) + // userDescGithub is the schema descriptor for github field. + userDescGithub := userFields[5].Descriptor() + // user.DefaultGithub holds the default value on creation for the github field. + user.DefaultGithub = userDescGithub.Default.(string) + // userDescLinkedin is the schema descriptor for linkedin field. + userDescLinkedin := userFields[6].Descriptor() + // user.DefaultLinkedin holds the default value on creation for the linkedin field. + user.DefaultLinkedin = userDescLinkedin.Default.(string) // userDescCreatedAt is the schema descriptor for created_at field. - userDescCreatedAt := userFields[4].Descriptor() + userDescCreatedAt := userFields[7].Descriptor() // user.DefaultCreatedAt holds the default value on creation for the created_at field. user.DefaultCreatedAt = userDescCreatedAt.Default.(func() time.Time) // userDescModifiedAt is the schema descriptor for modified_at field. - userDescModifiedAt := userFields[5].Descriptor() + userDescModifiedAt := userFields[8].Descriptor() // user.DefaultModifiedAt holds the default value on creation for the modified_at field. user.DefaultModifiedAt = userDescModifiedAt.Default.(func() time.Time) // user.UpdateDefaultModifiedAt holds the default value on update for the modified_at field. diff --git a/components/backend/ent/user.go b/components/backend/ent/user.go index 5a780f0b..7adc6758 100644 --- a/components/backend/ent/user.go +++ b/components/backend/ent/user.go @@ -26,6 +26,12 @@ type User struct { DisplayName string `json:"display_name,omitempty"` // Email of the user, same as in Keycloak Email string `json:"email,omitempty"` + // Renku account of the user. + Renku string `json:"renku,omitempty"` + // Github account of the user. + Github string `json:"github,omitempty"` + // LinkedIn account of the user. + Linkedin string `json:"linkedin,omitempty"` // Timestamp when the user was first seen. CreatedAt time.Time `json:"created_at,omitempty"` // Timestamp of the last profile update. @@ -356,7 +362,7 @@ func (*User) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case user.FieldUsername, user.FieldKeycloakID, user.FieldDisplayName, user.FieldEmail: + case user.FieldUsername, user.FieldKeycloakID, user.FieldDisplayName, user.FieldEmail, user.FieldRenku, user.FieldGithub, user.FieldLinkedin: values[i] = new(sql.NullString) case user.FieldCreatedAt, user.FieldModifiedAt: values[i] = new(sql.NullTime) @@ -407,6 +413,24 @@ func (_m *User) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Email = value.String } + case user.FieldRenku: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field renku", values[i]) + } else if value.Valid { + _m.Renku = value.String + } + case user.FieldGithub: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field github", values[i]) + } else if value.Valid { + _m.Github = value.String + } + case user.FieldLinkedin: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field linkedin", values[i]) + } else if value.Valid { + _m.Linkedin = value.String + } case user.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -607,6 +631,15 @@ func (_m *User) String() string { builder.WriteString("email=") builder.WriteString(_m.Email) builder.WriteString(", ") + builder.WriteString("renku=") + builder.WriteString(_m.Renku) + builder.WriteString(", ") + builder.WriteString("github=") + builder.WriteString(_m.Github) + builder.WriteString(", ") + builder.WriteString("linkedin=") + builder.WriteString(_m.Linkedin) + builder.WriteString(", ") builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(", ") diff --git a/components/backend/ent/user/user.go b/components/backend/ent/user/user.go index 6c31c2e7..be15183b 100644 --- a/components/backend/ent/user/user.go +++ b/components/backend/ent/user/user.go @@ -23,6 +23,12 @@ const ( FieldDisplayName = "display_name" // FieldEmail holds the string denoting the email field in the database. FieldEmail = "email" + // FieldRenku holds the string denoting the renku field in the database. + FieldRenku = "renku" + // FieldGithub holds the string denoting the github field in the database. + FieldGithub = "github" + // FieldLinkedin holds the string denoting the linkedin field in the database. + FieldLinkedin = "linkedin" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldModifiedAt holds the string denoting the modified_at field in the database. @@ -280,6 +286,9 @@ var Columns = []string{ FieldKeycloakID, FieldDisplayName, FieldEmail, + FieldRenku, + FieldGithub, + FieldLinkedin, FieldCreatedAt, FieldModifiedAt, } @@ -319,6 +328,12 @@ var ( DefaultDisplayName string // DefaultEmail holds the default value on creation for the "email" field. DefaultEmail string + // DefaultRenku holds the default value on creation for the "renku" field. + DefaultRenku string + // DefaultGithub holds the default value on creation for the "github" field. + DefaultGithub string + // DefaultLinkedin holds the default value on creation for the "linkedin" field. + DefaultLinkedin string // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time // DefaultModifiedAt holds the default value on creation for the "modified_at" field. @@ -357,6 +372,21 @@ func ByEmail(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldEmail, opts...).ToFunc() } +// ByRenku orders the results by the renku field. +func ByRenku(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldRenku, opts...).ToFunc() +} + +// ByGithub orders the results by the github field. +func ByGithub(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldGithub, opts...).ToFunc() +} + +// ByLinkedin orders the results by the linkedin field. +func ByLinkedin(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLinkedin, opts...).ToFunc() +} + // ByCreatedAt orders the results by the created_at field. func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() diff --git a/components/backend/ent/user/where.go b/components/backend/ent/user/where.go index 7c254b90..9452e4fb 100644 --- a/components/backend/ent/user/where.go +++ b/components/backend/ent/user/where.go @@ -76,6 +76,21 @@ func Email(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldEmail, v)) } +// Renku applies equality check predicate on the "renku" field. It's identical to RenkuEQ. +func Renku(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldRenku, v)) +} + +// Github applies equality check predicate on the "github" field. It's identical to GithubEQ. +func Github(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldGithub, v)) +} + +// Linkedin applies equality check predicate on the "linkedin" field. It's identical to LinkedinEQ. +func Linkedin(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldLinkedin, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) @@ -366,6 +381,231 @@ func EmailContainsFold(v string) predicate.User { return predicate.User(sql.FieldContainsFold(FieldEmail, v)) } +// RenkuEQ applies the EQ predicate on the "renku" field. +func RenkuEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldRenku, v)) +} + +// RenkuNEQ applies the NEQ predicate on the "renku" field. +func RenkuNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldRenku, v)) +} + +// RenkuIn applies the In predicate on the "renku" field. +func RenkuIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldRenku, vs...)) +} + +// RenkuNotIn applies the NotIn predicate on the "renku" field. +func RenkuNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldRenku, vs...)) +} + +// RenkuGT applies the GT predicate on the "renku" field. +func RenkuGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldRenku, v)) +} + +// RenkuGTE applies the GTE predicate on the "renku" field. +func RenkuGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldRenku, v)) +} + +// RenkuLT applies the LT predicate on the "renku" field. +func RenkuLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldRenku, v)) +} + +// RenkuLTE applies the LTE predicate on the "renku" field. +func RenkuLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldRenku, v)) +} + +// RenkuContains applies the Contains predicate on the "renku" field. +func RenkuContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldRenku, v)) +} + +// RenkuHasPrefix applies the HasPrefix predicate on the "renku" field. +func RenkuHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldRenku, v)) +} + +// RenkuHasSuffix applies the HasSuffix predicate on the "renku" field. +func RenkuHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldRenku, v)) +} + +// RenkuIsNil applies the IsNil predicate on the "renku" field. +func RenkuIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldRenku)) +} + +// RenkuNotNil applies the NotNil predicate on the "renku" field. +func RenkuNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldRenku)) +} + +// RenkuEqualFold applies the EqualFold predicate on the "renku" field. +func RenkuEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldRenku, v)) +} + +// RenkuContainsFold applies the ContainsFold predicate on the "renku" field. +func RenkuContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldRenku, v)) +} + +// GithubEQ applies the EQ predicate on the "github" field. +func GithubEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldGithub, v)) +} + +// GithubNEQ applies the NEQ predicate on the "github" field. +func GithubNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldGithub, v)) +} + +// GithubIn applies the In predicate on the "github" field. +func GithubIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldGithub, vs...)) +} + +// GithubNotIn applies the NotIn predicate on the "github" field. +func GithubNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldGithub, vs...)) +} + +// GithubGT applies the GT predicate on the "github" field. +func GithubGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldGithub, v)) +} + +// GithubGTE applies the GTE predicate on the "github" field. +func GithubGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldGithub, v)) +} + +// GithubLT applies the LT predicate on the "github" field. +func GithubLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldGithub, v)) +} + +// GithubLTE applies the LTE predicate on the "github" field. +func GithubLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldGithub, v)) +} + +// GithubContains applies the Contains predicate on the "github" field. +func GithubContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldGithub, v)) +} + +// GithubHasPrefix applies the HasPrefix predicate on the "github" field. +func GithubHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldGithub, v)) +} + +// GithubHasSuffix applies the HasSuffix predicate on the "github" field. +func GithubHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldGithub, v)) +} + +// GithubIsNil applies the IsNil predicate on the "github" field. +func GithubIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldGithub)) +} + +// GithubNotNil applies the NotNil predicate on the "github" field. +func GithubNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldGithub)) +} + +// GithubEqualFold applies the EqualFold predicate on the "github" field. +func GithubEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldGithub, v)) +} + +// GithubContainsFold applies the ContainsFold predicate on the "github" field. +func GithubContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldGithub, v)) +} + +// LinkedinEQ applies the EQ predicate on the "linkedin" field. +func LinkedinEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldLinkedin, v)) +} + +// LinkedinNEQ applies the NEQ predicate on the "linkedin" field. +func LinkedinNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldLinkedin, v)) +} + +// LinkedinIn applies the In predicate on the "linkedin" field. +func LinkedinIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldLinkedin, vs...)) +} + +// LinkedinNotIn applies the NotIn predicate on the "linkedin" field. +func LinkedinNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldLinkedin, vs...)) +} + +// LinkedinGT applies the GT predicate on the "linkedin" field. +func LinkedinGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldLinkedin, v)) +} + +// LinkedinGTE applies the GTE predicate on the "linkedin" field. +func LinkedinGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldLinkedin, v)) +} + +// LinkedinLT applies the LT predicate on the "linkedin" field. +func LinkedinLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldLinkedin, v)) +} + +// LinkedinLTE applies the LTE predicate on the "linkedin" field. +func LinkedinLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldLinkedin, v)) +} + +// LinkedinContains applies the Contains predicate on the "linkedin" field. +func LinkedinContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldLinkedin, v)) +} + +// LinkedinHasPrefix applies the HasPrefix predicate on the "linkedin" field. +func LinkedinHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldLinkedin, v)) +} + +// LinkedinHasSuffix applies the HasSuffix predicate on the "linkedin" field. +func LinkedinHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldLinkedin, v)) +} + +// LinkedinIsNil applies the IsNil predicate on the "linkedin" field. +func LinkedinIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldLinkedin)) +} + +// LinkedinNotNil applies the NotNil predicate on the "linkedin" field. +func LinkedinNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldLinkedin)) +} + +// LinkedinEqualFold applies the EqualFold predicate on the "linkedin" field. +func LinkedinEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldLinkedin, v)) +} + +// LinkedinContainsFold applies the ContainsFold predicate on the "linkedin" field. +func LinkedinContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldLinkedin, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) diff --git a/components/backend/ent/user_create.go b/components/backend/ent/user_create.go index 1e0a9bdf..27ed6874 100644 --- a/components/backend/ent/user_create.go +++ b/components/backend/ent/user_create.go @@ -78,6 +78,48 @@ func (_c *UserCreate) SetNillableEmail(v *string) *UserCreate { return _c } +// SetRenku sets the "renku" field. +func (_c *UserCreate) SetRenku(v string) *UserCreate { + _c.mutation.SetRenku(v) + return _c +} + +// SetNillableRenku sets the "renku" field if the given value is not nil. +func (_c *UserCreate) SetNillableRenku(v *string) *UserCreate { + if v != nil { + _c.SetRenku(*v) + } + return _c +} + +// SetGithub sets the "github" field. +func (_c *UserCreate) SetGithub(v string) *UserCreate { + _c.mutation.SetGithub(v) + return _c +} + +// SetNillableGithub sets the "github" field if the given value is not nil. +func (_c *UserCreate) SetNillableGithub(v *string) *UserCreate { + if v != nil { + _c.SetGithub(*v) + } + return _c +} + +// SetLinkedin sets the "linkedin" field. +func (_c *UserCreate) SetLinkedin(v string) *UserCreate { + _c.mutation.SetLinkedin(v) + return _c +} + +// SetNillableLinkedin sets the "linkedin" field if the given value is not nil. +func (_c *UserCreate) SetNillableLinkedin(v *string) *UserCreate { + if v != nil { + _c.SetLinkedin(*v) + } + return _c +} + // SetCreatedAt sets the "created_at" field. func (_c *UserCreate) SetCreatedAt(v time.Time) *UserCreate { _c.mutation.SetCreatedAt(v) @@ -553,6 +595,18 @@ func (_c *UserCreate) defaults() { v := user.DefaultEmail _c.mutation.SetEmail(v) } + if _, ok := _c.mutation.Renku(); !ok { + v := user.DefaultRenku + _c.mutation.SetRenku(v) + } + if _, ok := _c.mutation.Github(); !ok { + v := user.DefaultGithub + _c.mutation.SetGithub(v) + } + if _, ok := _c.mutation.Linkedin(); !ok { + v := user.DefaultLinkedin + _c.mutation.SetLinkedin(v) + } if _, ok := _c.mutation.CreatedAt(); !ok { v := user.DefaultCreatedAt() _c.mutation.SetCreatedAt(v) @@ -638,6 +692,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldEmail, field.TypeString, value) _node.Email = value } + if value, ok := _c.mutation.Renku(); ok { + _spec.SetField(user.FieldRenku, field.TypeString, value) + _node.Renku = value + } + if value, ok := _c.mutation.Github(); ok { + _spec.SetField(user.FieldGithub, field.TypeString, value) + _node.Github = value + } + if value, ok := _c.mutation.Linkedin(); ok { + _spec.SetField(user.FieldLinkedin, field.TypeString, value) + _node.Linkedin = value + } if value, ok := _c.mutation.CreatedAt(); ok { _spec.SetField(user.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value @@ -1182,6 +1248,60 @@ func (u *UserUpsert) ClearEmail() *UserUpsert { return u } +// SetRenku sets the "renku" field. +func (u *UserUpsert) SetRenku(v string) *UserUpsert { + u.Set(user.FieldRenku, v) + return u +} + +// UpdateRenku sets the "renku" field to the value that was provided on create. +func (u *UserUpsert) UpdateRenku() *UserUpsert { + u.SetExcluded(user.FieldRenku) + return u +} + +// ClearRenku clears the value of the "renku" field. +func (u *UserUpsert) ClearRenku() *UserUpsert { + u.SetNull(user.FieldRenku) + return u +} + +// SetGithub sets the "github" field. +func (u *UserUpsert) SetGithub(v string) *UserUpsert { + u.Set(user.FieldGithub, v) + return u +} + +// UpdateGithub sets the "github" field to the value that was provided on create. +func (u *UserUpsert) UpdateGithub() *UserUpsert { + u.SetExcluded(user.FieldGithub) + return u +} + +// ClearGithub clears the value of the "github" field. +func (u *UserUpsert) ClearGithub() *UserUpsert { + u.SetNull(user.FieldGithub) + return u +} + +// SetLinkedin sets the "linkedin" field. +func (u *UserUpsert) SetLinkedin(v string) *UserUpsert { + u.Set(user.FieldLinkedin, v) + return u +} + +// UpdateLinkedin sets the "linkedin" field to the value that was provided on create. +func (u *UserUpsert) UpdateLinkedin() *UserUpsert { + u.SetExcluded(user.FieldLinkedin) + return u +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (u *UserUpsert) ClearLinkedin() *UserUpsert { + u.SetNull(user.FieldLinkedin) + return u +} + // SetModifiedAt sets the "modified_at" field. func (u *UserUpsert) SetModifiedAt(v time.Time) *UserUpsert { u.Set(user.FieldModifiedAt, v) @@ -1315,6 +1435,69 @@ func (u *UserUpsertOne) ClearEmail() *UserUpsertOne { }) } +// SetRenku sets the "renku" field. +func (u *UserUpsertOne) SetRenku(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetRenku(v) + }) +} + +// UpdateRenku sets the "renku" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateRenku() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateRenku() + }) +} + +// ClearRenku clears the value of the "renku" field. +func (u *UserUpsertOne) ClearRenku() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearRenku() + }) +} + +// SetGithub sets the "github" field. +func (u *UserUpsertOne) SetGithub(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetGithub(v) + }) +} + +// UpdateGithub sets the "github" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateGithub() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateGithub() + }) +} + +// ClearGithub clears the value of the "github" field. +func (u *UserUpsertOne) ClearGithub() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearGithub() + }) +} + +// SetLinkedin sets the "linkedin" field. +func (u *UserUpsertOne) SetLinkedin(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetLinkedin(v) + }) +} + +// UpdateLinkedin sets the "linkedin" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateLinkedin() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateLinkedin() + }) +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (u *UserUpsertOne) ClearLinkedin() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearLinkedin() + }) +} + // SetModifiedAt sets the "modified_at" field. func (u *UserUpsertOne) SetModifiedAt(v time.Time) *UserUpsertOne { return u.Update(func(s *UserUpsert) { @@ -1617,6 +1800,69 @@ func (u *UserUpsertBulk) ClearEmail() *UserUpsertBulk { }) } +// SetRenku sets the "renku" field. +func (u *UserUpsertBulk) SetRenku(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetRenku(v) + }) +} + +// UpdateRenku sets the "renku" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateRenku() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateRenku() + }) +} + +// ClearRenku clears the value of the "renku" field. +func (u *UserUpsertBulk) ClearRenku() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearRenku() + }) +} + +// SetGithub sets the "github" field. +func (u *UserUpsertBulk) SetGithub(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetGithub(v) + }) +} + +// UpdateGithub sets the "github" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateGithub() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateGithub() + }) +} + +// ClearGithub clears the value of the "github" field. +func (u *UserUpsertBulk) ClearGithub() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearGithub() + }) +} + +// SetLinkedin sets the "linkedin" field. +func (u *UserUpsertBulk) SetLinkedin(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetLinkedin(v) + }) +} + +// UpdateLinkedin sets the "linkedin" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateLinkedin() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateLinkedin() + }) +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (u *UserUpsertBulk) ClearLinkedin() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearLinkedin() + }) +} + // SetModifiedAt sets the "modified_at" field. func (u *UserUpsertBulk) SetModifiedAt(v time.Time) *UserUpsertBulk { return u.Update(func(s *UserUpsert) { diff --git a/components/backend/ent/user_update.go b/components/backend/ent/user_update.go index e4419bcc..495a2e61 100644 --- a/components/backend/ent/user_update.go +++ b/components/backend/ent/user_update.go @@ -111,6 +111,66 @@ func (_u *UserUpdate) ClearEmail() *UserUpdate { return _u } +// SetRenku sets the "renku" field. +func (_u *UserUpdate) SetRenku(v string) *UserUpdate { + _u.mutation.SetRenku(v) + return _u +} + +// SetNillableRenku sets the "renku" field if the given value is not nil. +func (_u *UserUpdate) SetNillableRenku(v *string) *UserUpdate { + if v != nil { + _u.SetRenku(*v) + } + return _u +} + +// ClearRenku clears the value of the "renku" field. +func (_u *UserUpdate) ClearRenku() *UserUpdate { + _u.mutation.ClearRenku() + return _u +} + +// SetGithub sets the "github" field. +func (_u *UserUpdate) SetGithub(v string) *UserUpdate { + _u.mutation.SetGithub(v) + return _u +} + +// SetNillableGithub sets the "github" field if the given value is not nil. +func (_u *UserUpdate) SetNillableGithub(v *string) *UserUpdate { + if v != nil { + _u.SetGithub(*v) + } + return _u +} + +// ClearGithub clears the value of the "github" field. +func (_u *UserUpdate) ClearGithub() *UserUpdate { + _u.mutation.ClearGithub() + return _u +} + +// SetLinkedin sets the "linkedin" field. +func (_u *UserUpdate) SetLinkedin(v string) *UserUpdate { + _u.mutation.SetLinkedin(v) + return _u +} + +// SetNillableLinkedin sets the "linkedin" field if the given value is not nil. +func (_u *UserUpdate) SetNillableLinkedin(v *string) *UserUpdate { + if v != nil { + _u.SetLinkedin(*v) + } + return _u +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (_u *UserUpdate) ClearLinkedin() *UserUpdate { + _u.mutation.ClearLinkedin() + return _u +} + // SetModifiedAt sets the "modified_at" field. func (_u *UserUpdate) SetModifiedAt(v time.Time) *UserUpdate { _u.mutation.SetModifiedAt(v) @@ -1134,6 +1194,24 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.EmailCleared() { _spec.ClearField(user.FieldEmail, field.TypeString) } + if value, ok := _u.mutation.Renku(); ok { + _spec.SetField(user.FieldRenku, field.TypeString, value) + } + if _u.mutation.RenkuCleared() { + _spec.ClearField(user.FieldRenku, field.TypeString) + } + if value, ok := _u.mutation.Github(); ok { + _spec.SetField(user.FieldGithub, field.TypeString, value) + } + if _u.mutation.GithubCleared() { + _spec.ClearField(user.FieldGithub, field.TypeString) + } + if value, ok := _u.mutation.Linkedin(); ok { + _spec.SetField(user.FieldLinkedin, field.TypeString, value) + } + if _u.mutation.LinkedinCleared() { + _spec.ClearField(user.FieldLinkedin, field.TypeString) + } if value, ok := _u.mutation.ModifiedAt(); ok { _spec.SetField(user.FieldModifiedAt, field.TypeTime, value) } @@ -2419,6 +2497,66 @@ func (_u *UserUpdateOne) ClearEmail() *UserUpdateOne { return _u } +// SetRenku sets the "renku" field. +func (_u *UserUpdateOne) SetRenku(v string) *UserUpdateOne { + _u.mutation.SetRenku(v) + return _u +} + +// SetNillableRenku sets the "renku" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableRenku(v *string) *UserUpdateOne { + if v != nil { + _u.SetRenku(*v) + } + return _u +} + +// ClearRenku clears the value of the "renku" field. +func (_u *UserUpdateOne) ClearRenku() *UserUpdateOne { + _u.mutation.ClearRenku() + return _u +} + +// SetGithub sets the "github" field. +func (_u *UserUpdateOne) SetGithub(v string) *UserUpdateOne { + _u.mutation.SetGithub(v) + return _u +} + +// SetNillableGithub sets the "github" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableGithub(v *string) *UserUpdateOne { + if v != nil { + _u.SetGithub(*v) + } + return _u +} + +// ClearGithub clears the value of the "github" field. +func (_u *UserUpdateOne) ClearGithub() *UserUpdateOne { + _u.mutation.ClearGithub() + return _u +} + +// SetLinkedin sets the "linkedin" field. +func (_u *UserUpdateOne) SetLinkedin(v string) *UserUpdateOne { + _u.mutation.SetLinkedin(v) + return _u +} + +// SetNillableLinkedin sets the "linkedin" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableLinkedin(v *string) *UserUpdateOne { + if v != nil { + _u.SetLinkedin(*v) + } + return _u +} + +// ClearLinkedin clears the value of the "linkedin" field. +func (_u *UserUpdateOne) ClearLinkedin() *UserUpdateOne { + _u.mutation.ClearLinkedin() + return _u +} + // SetModifiedAt sets the "modified_at" field. func (_u *UserUpdateOne) SetModifiedAt(v time.Time) *UserUpdateOne { _u.mutation.SetModifiedAt(v) @@ -3472,6 +3610,24 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if _u.mutation.EmailCleared() { _spec.ClearField(user.FieldEmail, field.TypeString) } + if value, ok := _u.mutation.Renku(); ok { + _spec.SetField(user.FieldRenku, field.TypeString, value) + } + if _u.mutation.RenkuCleared() { + _spec.ClearField(user.FieldRenku, field.TypeString) + } + if value, ok := _u.mutation.Github(); ok { + _spec.SetField(user.FieldGithub, field.TypeString, value) + } + if _u.mutation.GithubCleared() { + _spec.ClearField(user.FieldGithub, field.TypeString) + } + if value, ok := _u.mutation.Linkedin(); ok { + _spec.SetField(user.FieldLinkedin, field.TypeString, value) + } + if _u.mutation.LinkedinCleared() { + _spec.ClearField(user.FieldLinkedin, field.TypeString) + } if value, ok := _u.mutation.ModifiedAt(); ok { _spec.SetField(user.FieldModifiedAt, field.TypeTime, value) } diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index c6a31e56..a63433c4 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -22,6 +22,9 @@ func userEntryFromEnt(u *ent.User) *userEnts.User { KeycloakId: u.KeycloakID, DisplayName: u.DisplayName, Email: u.Email, + Renku: u.Renku, + Github: u.Github, + Linkedin: u.Linkedin, CreatedAt: timestamppb.New(u.CreatedAt), ModifiedAt: timestamppb.New(u.ModifiedAt), } diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index f5b45f5a..404081b3 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -61,6 +61,55 @@ func (s *UserService) List( return &msgs.ListResponse{Users: entries}, nil } +func (s *UserService) Edit( + ctx context.Context, + req *msgs.EditRequest, +) (*msgs.EditResponse, error) { + if err := s.enforcer.RequirePermission(ctx, "", m.User, m.Write); err != nil { + return nil, err + } + + id, err := uuid.Parse(req.GetUserId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) + } + + updateQuery := s.dbClient.User.UpdateOneID(id) + updateQuery, err = updateLinkedin(updateQuery, req.Linkedin) + if err != nil { + return nil, err + } + updateQuery, err = updateGithub(updateQuery, req.Linkedin) + if err != nil { + return nil, err + } + updateQuery, err = updateRenku(updateQuery, req.Linkedin) + if err != nil { + return nil, err + } + + u, err := updateQuery.Save(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + if err != nil { + slog.Error("get global roles", "err", err) + + return nil, status.Error(codes.Internal, "couldn't resolve user roles") + } + entry := userEntryFromEnt(u) + entry.Roles = append(entry.Roles, globalRoles...) + + return &msgs.EditResponse{User: entry}, nil +} + func (s *UserService) Get( ctx context.Context, req *msgs.GetRequest, diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go new file mode 100644 index 00000000..7310db86 --- /dev/null +++ b/components/backend/internal/service/userlinks.go @@ -0,0 +1,113 @@ +package service + +import ( + "fmt" + "log/slog" + "regexp" + + "github.com/swissdatasciencecenter/hackagon/components/backend/ent" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + linkedInRe = regexp.MustCompile(`^https://linkedin\.com/in/([A-Za-z0-9-]{3,100})/?$`) + githubRe = regexp.MustCompile(`^https://github\.com/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)/?$`) + renkuRe = regexp.MustCompile( + `^https://renkulab\.io/u/([A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)/?$`, + ) +) + +func linkedInUsername(url string) (string, error) { + m := linkedInRe.FindStringSubmatch(url) + if m == nil { + return "", fmt.Errorf("url '%s' does not match regexp", url) + } + + return m[1], nil +} + +func githubUsername(url string) (string, error) { + m := githubRe.FindStringSubmatch(url) + if m == nil || len(m[1]) > 39 { + return "", fmt.Errorf("url '%s' does not match regexp", url) + } + return m[1], nil +} + +func renkuUsername(url string) (string, error) { + m := renkuRe.FindStringSubmatch(url) + if m == nil { + return "", fmt.Errorf("url '%s' does not match regexp", url) + } + return m[1], nil +} + +func updateLinkedin(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { + if req == nil { + return updateQuery, nil + } + + var username string + if *req == "" { + username = *req + } else if u, err := linkedInUsername(*req); err == nil { + username = u + } else { + slog.Error("Couldn't get LinkedIn username.", "error", err.Error()) + + return nil, status.Errorf( + codes.InvalidArgument, + "invalid LinkedIn URL '%s'", + *req, + ) + } + + return updateQuery.SetLinkedin(username), nil +} + +func updateGithub(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { + if req == nil { + return updateQuery, nil + } + + var username string + if *req == "" { + username = *req + } else if u, err := githubUsername(*req); err == nil { + username = u + } else { + slog.Error("Couldn't get GitHub username.", "error", err.Error()) + + return nil, status.Errorf( + codes.InvalidArgument, + "invalid GitHub URL '%s'", + *req, + ) + } + + return updateQuery.SetGithub(username), nil +} + +func updateRenku(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { + if req == nil { + return updateQuery, nil + } + + var username string + if *req == "" { + username = *req + } else if u, err := renkuUsername(*req); err == nil { + username = u + } else { + slog.Error("Couldn't get Renku username.", "error", err.Error()) + + return nil, status.Errorf( + codes.InvalidArgument, + "invalid Renku URL '%s'", + *req, + ) + } + + return updateQuery.SetRenku(username), nil +} From 4d6932f4603b80d795176872e193aa82ee3c988b Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 12:20:14 +0200 Subject: [PATCH 03/23] feat: store only username but return links; add tests --- api/proto/API.md | 12 ++-- api/proto/user/entities/user.proto | 6 +- .../user/messages/user_svc/edit_request.proto | 6 +- .../internal/proto/user/entities/user.pb.go | 29 ++++---- .../user/messages/user_svc/edit_request.pb.go | 42 +++++------ .../backend/internal/service/mappers.go | 6 +- .../backend/internal/service/user_service.go | 6 +- .../internal/service/user_service_test.go | 53 ++++++++++++++ .../backend/internal/service/userlinks.go | 42 +++++++++++ .../grpc/generated/user/entities/user.ts | 70 +++++++++++-------- .../user/messages/user_svc/edit_request.ts | 62 +++++++++------- 11 files changed, 229 insertions(+), 105 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index 631d7a61..0685cee6 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -793,9 +793,9 @@ See components/backend/internal/middleware/rbac.go. | created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | | display_name | [string](#string) | | | | email | [string](#string) | | | -| renku | [string](#string) | | | -| github | [string](#string) | | | -| linkedin | [string](#string) | | | +| renku_url | [string](#string) | | | +| github_url | [string](#string) | | | +| linkedin_url | [string](#string) | | | | roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | | modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | @@ -5771,9 +5771,9 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | user_id | [string](#string) | | | -| renku | [string](#string) | optional | | -| github | [string](#string) | optional | | -| linkedin | [string](#string) | optional | | +| renku_url | [string](#string) | optional | | +| github_url | [string](#string) | optional | | +| linkedin_url | [string](#string) | optional | | diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index deefbf09..0f1c6b22 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -14,9 +14,9 @@ message User { google.protobuf.Timestamp created_at = 4; string display_name = 5; string email = 6; - string renku = 7; - string github = 8; - string linkedin = 9; + string renku_url = 7; + string github_url = 8; + string linkedin_url = 9; // Populated from casbin on fetch; not persisted in ent DB. repeated GlobalRole roles = 10; google.protobuf.Timestamp modified_at = 11; diff --git a/api/proto/user/messages/user_svc/edit_request.proto b/api/proto/user/messages/user_svc/edit_request.proto index 7c60dbe4..2502db77 100644 --- a/api/proto/user/messages/user_svc/edit_request.proto +++ b/api/proto/user/messages/user_svc/edit_request.proto @@ -6,7 +6,7 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message EditRequest { string user_id = 1; - optional string renku = 2; - optional string github = 3; - optional string linkedin = 4; + optional string renku_url = 2; + optional string github_url = 3; + optional string linkedin_url = 4; } diff --git a/components/backend/internal/proto/user/entities/user.pb.go b/components/backend/internal/proto/user/entities/user.pb.go index ab2d3dac..c76e9e83 100644 --- a/components/backend/internal/proto/user/entities/user.pb.go +++ b/components/backend/internal/proto/user/entities/user.pb.go @@ -30,9 +30,9 @@ type User struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` Email string `protobuf:"bytes,6,opt,name=email,proto3" json:"email,omitempty"` - Renku string `protobuf:"bytes,7,opt,name=renku,proto3" json:"renku,omitempty"` - Github string `protobuf:"bytes,8,opt,name=github,proto3" json:"github,omitempty"` - Linkedin string `protobuf:"bytes,9,opt,name=linkedin,proto3" json:"linkedin,omitempty"` + RenkuUrl string `protobuf:"bytes,7,opt,name=renku_url,json=renkuUrl,proto3" json:"renku_url,omitempty"` + GithubUrl string `protobuf:"bytes,8,opt,name=github_url,json=githubUrl,proto3" json:"github_url,omitempty"` + LinkedinUrl string `protobuf:"bytes,9,opt,name=linkedin_url,json=linkedinUrl,proto3" json:"linkedin_url,omitempty"` // Populated from casbin on fetch; not persisted in ent DB. Roles []GlobalRole `protobuf:"varint,10,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` @@ -112,23 +112,23 @@ func (x *User) GetEmail() string { return "" } -func (x *User) GetRenku() string { +func (x *User) GetRenkuUrl() string { if x != nil { - return x.Renku + return x.RenkuUrl } return "" } -func (x *User) GetGithub() string { +func (x *User) GetGithubUrl() string { if x != nil { - return x.Github + return x.GithubUrl } return "" } -func (x *User) GetLinkedin() string { +func (x *User) GetLinkedinUrl() string { if x != nil { - return x.Linkedin + return x.LinkedinUrl } return "" } @@ -151,7 +151,7 @@ var File_user_entities_user_proto protoreflect.FileDescriptor const file_user_entities_user_proto_rawDesc = "" + "\n" + - "\x18user/entities/user.proto\x12\ruser.entities\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fuser/entities/global_role.proto\"\xff\x02\n" + + "\x18user/entities/user.proto\x12\ruser.entities\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1fuser/entities/global_role.proto\"\x94\x03\n" + "\x04User\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x1f\n" + @@ -160,10 +160,11 @@ const file_user_entities_user_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" + "\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x06 \x01(\tR\x05email\x12\x14\n" + - "\x05renku\x18\a \x01(\tR\x05renku\x12\x16\n" + - "\x06github\x18\b \x01(\tR\x06github\x12\x1a\n" + - "\blinkedin\x18\t \x01(\tR\blinkedin\x12/\n" + + "\x05email\x18\x06 \x01(\tR\x05email\x12\x1b\n" + + "\trenku_url\x18\a \x01(\tR\brenkuUrl\x12\x1d\n" + + "\n" + + "github_url\x18\b \x01(\tR\tgithubUrl\x12!\n" + + "\flinkedin_url\x18\t \x01(\tR\vlinkedinUrl\x12/\n" + "\x05roles\x18\n" + " \x03(\x0e2\x19.user.entities.GlobalRoleR\x05roles\x12;\n" + "\vmodified_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" + diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go index 42b92e0b..c312130b 100644 --- a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go @@ -24,9 +24,9 @@ const ( type EditRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Renku *string `protobuf:"bytes,2,opt,name=renku,proto3,oneof" json:"renku,omitempty"` - Github *string `protobuf:"bytes,3,opt,name=github,proto3,oneof" json:"github,omitempty"` - Linkedin *string `protobuf:"bytes,4,opt,name=linkedin,proto3,oneof" json:"linkedin,omitempty"` + RenkuUrl *string `protobuf:"bytes,2,opt,name=renku_url,json=renkuUrl,proto3,oneof" json:"renku_url,omitempty"` + GithubUrl *string `protobuf:"bytes,3,opt,name=github_url,json=githubUrl,proto3,oneof" json:"github_url,omitempty"` + LinkedinUrl *string `protobuf:"bytes,4,opt,name=linkedin_url,json=linkedinUrl,proto3,oneof" json:"linkedin_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -68,23 +68,23 @@ func (x *EditRequest) GetUserId() string { return "" } -func (x *EditRequest) GetRenku() string { - if x != nil && x.Renku != nil { - return *x.Renku +func (x *EditRequest) GetRenkuUrl() string { + if x != nil && x.RenkuUrl != nil { + return *x.RenkuUrl } return "" } -func (x *EditRequest) GetGithub() string { - if x != nil && x.Github != nil { - return *x.Github +func (x *EditRequest) GetGithubUrl() string { + if x != nil && x.GithubUrl != nil { + return *x.GithubUrl } return "" } -func (x *EditRequest) GetLinkedin() string { - if x != nil && x.Linkedin != nil { - return *x.Linkedin +func (x *EditRequest) GetLinkedinUrl() string { + if x != nil && x.LinkedinUrl != nil { + return *x.LinkedinUrl } return "" } @@ -93,15 +93,17 @@ var File_user_messages_user_svc_edit_request_proto protoreflect.FileDescriptor const file_user_messages_user_svc_edit_request_proto_rawDesc = "" + "\n" + - ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\"\xa1\x01\n" + + ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\"\xc2\x01\n" + "\vEditRequest\x12\x17\n" + - "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x19\n" + - "\x05renku\x18\x02 \x01(\tH\x00R\x05renku\x88\x01\x01\x12\x1b\n" + - "\x06github\x18\x03 \x01(\tH\x01R\x06github\x88\x01\x01\x12\x1f\n" + - "\blinkedin\x18\x04 \x01(\tH\x02R\blinkedin\x88\x01\x01B\b\n" + - "\x06_renkuB\t\n" + - "\a_githubB\v\n" + - "\t_linkedinBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12 \n" + + "\trenku_url\x18\x02 \x01(\tH\x00R\brenkuUrl\x88\x01\x01\x12\"\n" + + "\n" + + "github_url\x18\x03 \x01(\tH\x01R\tgithubUrl\x88\x01\x01\x12&\n" + + "\flinkedin_url\x18\x04 \x01(\tH\x02R\vlinkedinUrl\x88\x01\x01B\f\n" + + "\n" + + "_renku_urlB\r\n" + + "\v_github_urlB\x0f\n" + + "\r_linkedin_urlBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" var ( file_user_messages_user_svc_edit_request_proto_rawDescOnce sync.Once diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index a63433c4..d285dbe8 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -22,9 +22,9 @@ func userEntryFromEnt(u *ent.User) *userEnts.User { KeycloakId: u.KeycloakID, DisplayName: u.DisplayName, Email: u.Email, - Renku: u.Renku, - Github: u.Github, - Linkedin: u.Linkedin, + RenkuUrl: renderRenkuURL(u.Renku), + GithubUrl: renderGithubURL(u.Github), + LinkedinUrl: renderLinkedinURL(u.Linkedin), CreatedAt: timestamppb.New(u.CreatedAt), ModifiedAt: timestamppb.New(u.ModifiedAt), } diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 404081b3..8a05423f 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -75,15 +75,15 @@ func (s *UserService) Edit( } updateQuery := s.dbClient.User.UpdateOneID(id) - updateQuery, err = updateLinkedin(updateQuery, req.Linkedin) + updateQuery, err = updateLinkedin(updateQuery, req.LinkedinUrl) if err != nil { return nil, err } - updateQuery, err = updateGithub(updateQuery, req.Linkedin) + updateQuery, err = updateGithub(updateQuery, req.GithubUrl) if err != nil { return nil, err } - updateQuery, err = updateRenku(updateQuery, req.Linkedin) + updateQuery, err = updateRenku(updateQuery, req.RenkuUrl) if err != nil { return nil, err } diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 616757e0..45c5db3f 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -20,6 +20,7 @@ import ( userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" + "github.com/swissdatasciencecenter/hackagon/components/backend/internal/service" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" ) @@ -51,6 +52,9 @@ var _ = Describe("UserService", func() { SetUsername("get-target-username"). SetDisplayName("Get Target"). SetEmail("get@example.com"). + SetRenku("bob-renku"). + SetLinkedin("bob-linkedin"). + SetGithub("bob-github"). Save(context.Background()) Expect(err).NotTo(HaveOccurred()) }) @@ -71,6 +75,9 @@ var _ = Describe("UserService", func() { Expect(u.GetUsername()).To(Equal("get-target-username")) Expect(u.GetDisplayName()).To(Equal("Get Target")) Expect(u.GetEmail()).To(Equal("get@example.com")) + Expect(u.GetRenkuUrl()).To(Equal(service.RenkuPrefix + "bob-renku")) + Expect(u.GetLinkedinUrl()).To(Equal(service.LinkedInPrefix + "bob-linkedin")) + Expect(u.GetGithubUrl()).To(Equal(service.GithubPrefix + "bob-github")) Expect(u.GetKeycloakId()).To(Equal("get-target")) Expect(u.GetCreatedAt()).NotTo(BeNil()) Expect(u.GetModifiedAt()).NotTo(BeNil()) @@ -853,4 +860,50 @@ var _ = Describe("UserService", func() { Expect(st.Code()).To(Equal(codes.PermissionDenied)) }) }) + + Describe("Edit", func() { + + var targetUser *ent.User + + newRenku := "https://renkulab.io/u/bob-renku-new" + newGithub := "https://github.com/bob-github-new" + newLinkedin := "https://linkedin.com/in/bob-linkedin-new" + + BeforeEach(func() { + var err error + targetUser, err = dbClient.User.Create(). + SetKeycloakID("get-target"). + SetUsername("get-target-username"). + SetDisplayName("Get Target"). + SetEmail("get@example.com"). + SetRenku("bob-renku"). + SetLinkedin("bob-linkedin"). + SetGithub("bob-github"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + }) + + It("correctly changes the links values", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + resp, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + RenkuUrl: &newRenku, + GithubUrl: &newGithub, + LinkedinUrl: &newLinkedin, + }) + + Expect(err).NotTo(HaveOccurred()) + + u := resp.GetUser() + + Expect(u.GetRenkuUrl()).To(Equal(newRenku)) + Expect(u.GetGithubUrl()).To(Equal(newGithub)) + Expect(u.GetLinkedinUrl()).To(Equal(newLinkedin)) + }) + }) }) diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 7310db86..70c09fd4 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -3,6 +3,7 @@ package service import ( "fmt" "log/slog" + "net/url" "regexp" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -10,6 +11,12 @@ import ( "google.golang.org/grpc/status" ) +const ( + LinkedInPrefix = "https://linkedin.com/in/" + GithubPrefix = "https://github.com/" + RenkuPrefix = "https://renkulab.io/u/" +) + var ( linkedInRe = regexp.MustCompile(`^https://linkedin\.com/in/([A-Za-z0-9-]{3,100})/?$`) githubRe = regexp.MustCompile(`^https://github\.com/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)/?$`) @@ -111,3 +118,38 @@ func updateRenku(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOn return updateQuery.SetRenku(username), nil } + +func renderLinkedinURL(username string) string { + return renderLink(LinkedInPrefix, username) +} + +func renderGithubURL(username string) string { + return renderLink(GithubPrefix, username) +} + +func renderRenkuURL(username string) string { + return renderLink(RenkuPrefix, username) +} + +func renderLink(prefix, username string) string { + if username == "" { + return "" + } + + u, err := url.JoinPath(prefix, username) + if err != nil { + slog.Error( + "Could not create link URL.", + "error", + err.Error(), + "prefix", + prefix, + "username", + username, + ) + + return "" + } + + return u +} diff --git a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts index 90d7e830..de703adc 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts @@ -18,9 +18,9 @@ export interface User { createdAt: Date | undefined; displayName: string; email: string; - renku: string; - github: string; - linkedin: string; + renkuUrl: string; + githubUrl: string; + linkedinUrl: string; /** Populated from casbin on fetch; not persisted in ent DB. */ roles: GlobalRole[]; modifiedAt: Date | undefined; @@ -34,9 +34,9 @@ function createBaseUser(): User { createdAt: undefined, displayName: "", email: "", - renku: "", - github: "", - linkedin: "", + renkuUrl: "", + githubUrl: "", + linkedinUrl: "", roles: [], modifiedAt: undefined, }; @@ -62,14 +62,14 @@ export const User: MessageFns = { if (message.email !== "") { writer.uint32(50).string(message.email); } - if (message.renku !== "") { - writer.uint32(58).string(message.renku); + if (message.renkuUrl !== "") { + writer.uint32(58).string(message.renkuUrl); } - if (message.github !== "") { - writer.uint32(66).string(message.github); + if (message.githubUrl !== "") { + writer.uint32(66).string(message.githubUrl); } - if (message.linkedin !== "") { - writer.uint32(74).string(message.linkedin); + if (message.linkedinUrl !== "") { + writer.uint32(74).string(message.linkedinUrl); } writer.uint32(82).fork(); for (const v of message.roles) { @@ -142,7 +142,7 @@ export const User: MessageFns = { break; } - message.renku = reader.string(); + message.renkuUrl = reader.string(); continue; } case 8: { @@ -150,7 +150,7 @@ export const User: MessageFns = { break; } - message.github = reader.string(); + message.githubUrl = reader.string(); continue; } case 9: { @@ -158,7 +158,7 @@ export const User: MessageFns = { break; } - message.linkedin = reader.string(); + message.linkedinUrl = reader.string(); continue; } case 10: { @@ -216,10 +216,24 @@ export const User: MessageFns = { ? globalThis.String(object.display_name) : "", email: isSet(object.email) ? globalThis.String(object.email) : "", - renku: isSet(object.renku) ? globalThis.String(object.renku) : "", - github: isSet(object.github) ? globalThis.String(object.github) : "", - linkedin: isSet(object.linkedin) ? globalThis.String(object.linkedin) : "", - roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalRoleFromJSON(e)) : [], + renkuUrl: isSet(object.renkuUrl) + ? globalThis.String(object.renkuUrl) + : isSet(object.renku_url) + ? globalThis.String(object.renku_url) + : "", + githubUrl: isSet(object.githubUrl) + ? globalThis.String(object.githubUrl) + : isSet(object.github_url) + ? globalThis.String(object.github_url) + : "", + linkedinUrl: isSet(object.linkedinUrl) + ? globalThis.String(object.linkedinUrl) + : isSet(object.linkedin_url) + ? globalThis.String(object.linkedin_url) + : "", + roles: globalThis.Array.isArray(object?.roles) + ? object.roles.map((e: any) => globalRoleFromJSON(e)) + : [], modifiedAt: isSet(object.modifiedAt) ? fromJsonTimestamp(object.modifiedAt) : isSet(object.modified_at) @@ -248,14 +262,14 @@ export const User: MessageFns = { if (message.email !== "") { obj.email = message.email; } - if (message.renku !== "") { - obj.renku = message.renku; + if (message.renkuUrl !== "") { + obj.renkuUrl = message.renkuUrl; } - if (message.github !== "") { - obj.github = message.github; + if (message.githubUrl !== "") { + obj.githubUrl = message.githubUrl; } - if (message.linkedin !== "") { - obj.linkedin = message.linkedin; + if (message.linkedinUrl !== "") { + obj.linkedinUrl = message.linkedinUrl; } if (message.roles?.length) { obj.roles = message.roles.map((e) => globalRoleToJSON(e)); @@ -277,9 +291,9 @@ export const User: MessageFns = { message.createdAt = object.createdAt ?? undefined; message.displayName = object.displayName ?? ""; message.email = object.email ?? ""; - message.renku = object.renku ?? ""; - message.github = object.github ?? ""; - message.linkedin = object.linkedin ?? ""; + message.renkuUrl = object.renkuUrl ?? ""; + message.githubUrl = object.githubUrl ?? ""; + message.linkedinUrl = object.linkedinUrl ?? ""; message.roles = object.roles?.map((e) => e) || []; message.modifiedAt = object.modifiedAt ?? undefined; return message; diff --git a/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts index 190ce744..657215b9 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_request.ts @@ -11,13 +11,13 @@ export const protobufPackage = "user.messages.user_svc"; export interface EditRequest { userId: string; - renku?: string | undefined; - github?: string | undefined; - linkedin?: string | undefined; + renkuUrl?: string | undefined; + githubUrl?: string | undefined; + linkedinUrl?: string | undefined; } function createBaseEditRequest(): EditRequest { - return { userId: "", renku: undefined, github: undefined, linkedin: undefined }; + return { userId: "", renkuUrl: undefined, githubUrl: undefined, linkedinUrl: undefined }; } export const EditRequest: MessageFns = { @@ -25,14 +25,14 @@ export const EditRequest: MessageFns = { if (message.userId !== "") { writer.uint32(10).string(message.userId); } - if (message.renku !== undefined) { - writer.uint32(18).string(message.renku); + if (message.renkuUrl !== undefined) { + writer.uint32(18).string(message.renkuUrl); } - if (message.github !== undefined) { - writer.uint32(26).string(message.github); + if (message.githubUrl !== undefined) { + writer.uint32(26).string(message.githubUrl); } - if (message.linkedin !== undefined) { - writer.uint32(34).string(message.linkedin); + if (message.linkedinUrl !== undefined) { + writer.uint32(34).string(message.linkedinUrl); } return writer; }, @@ -57,7 +57,7 @@ export const EditRequest: MessageFns = { break; } - message.renku = reader.string(); + message.renkuUrl = reader.string(); continue; } case 3: { @@ -65,7 +65,7 @@ export const EditRequest: MessageFns = { break; } - message.github = reader.string(); + message.githubUrl = reader.string(); continue; } case 4: { @@ -73,7 +73,7 @@ export const EditRequest: MessageFns = { break; } - message.linkedin = reader.string(); + message.linkedinUrl = reader.string(); continue; } } @@ -92,9 +92,21 @@ export const EditRequest: MessageFns = { : isSet(object.user_id) ? globalThis.String(object.user_id) : "", - renku: isSet(object.renku) ? globalThis.String(object.renku) : undefined, - github: isSet(object.github) ? globalThis.String(object.github) : undefined, - linkedin: isSet(object.linkedin) ? globalThis.String(object.linkedin) : undefined, + renkuUrl: isSet(object.renkuUrl) + ? globalThis.String(object.renkuUrl) + : isSet(object.renku_url) + ? globalThis.String(object.renku_url) + : undefined, + githubUrl: isSet(object.githubUrl) + ? globalThis.String(object.githubUrl) + : isSet(object.github_url) + ? globalThis.String(object.github_url) + : undefined, + linkedinUrl: isSet(object.linkedinUrl) + ? globalThis.String(object.linkedinUrl) + : isSet(object.linkedin_url) + ? globalThis.String(object.linkedin_url) + : undefined, }; }, @@ -103,14 +115,14 @@ export const EditRequest: MessageFns = { if (message.userId !== "") { obj.userId = message.userId; } - if (message.renku !== undefined) { - obj.renku = message.renku; + if (message.renkuUrl !== undefined) { + obj.renkuUrl = message.renkuUrl; } - if (message.github !== undefined) { - obj.github = message.github; + if (message.githubUrl !== undefined) { + obj.githubUrl = message.githubUrl; } - if (message.linkedin !== undefined) { - obj.linkedin = message.linkedin; + if (message.linkedinUrl !== undefined) { + obj.linkedinUrl = message.linkedinUrl; } return obj; }, @@ -121,9 +133,9 @@ export const EditRequest: MessageFns = { fromPartial(object: DeepPartial): EditRequest { const message = createBaseEditRequest(); message.userId = object.userId ?? ""; - message.renku = object.renku ?? undefined; - message.github = object.github ?? undefined; - message.linkedin = object.linkedin ?? undefined; + message.renkuUrl = object.renkuUrl ?? undefined; + message.githubUrl = object.githubUrl ?? undefined; + message.linkedinUrl = object.linkedinUrl ?? undefined; return message; }, }; From d054a684c2dd570d3e646c99020ef2cb0a5395fd Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 13:40:53 +0200 Subject: [PATCH 04/23] fix: backward compatibility for proto ids; improve links regexps --- api/proto/API.md | 6 +- api/proto/user/entities/user.proto | 12 +- .../internal/proto/user/entities/user.pb.go | 55 ++++----- .../backend/internal/service/userlinks.go | 42 ++++--- .../internal/service/userlinks_test.go | 42 +++++++ .../grpc/generated/user/entities/user.ts | 107 +++++++++--------- 6 files changed, 163 insertions(+), 101 deletions(-) create mode 100644 components/backend/internal/service/userlinks_test.go diff --git a/api/proto/API.md b/api/proto/API.md index 0685cee6..6bb3d394 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -793,11 +793,11 @@ See components/backend/internal/middleware/rbac.go. | created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | | display_name | [string](#string) | | | | email | [string](#string) | | | -| renku_url | [string](#string) | | | -| github_url | [string](#string) | | | -| linkedin_url | [string](#string) | | | | roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | | modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | +| renku_url | [string](#string) | | User accounts links. | +| github_url | [string](#string) | | | +| linkedin_url | [string](#string) | | | diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index 0f1c6b22..a8afaedd 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -14,10 +14,12 @@ message User { google.protobuf.Timestamp created_at = 4; string display_name = 5; string email = 6; - string renku_url = 7; - string github_url = 8; - string linkedin_url = 9; // Populated from casbin on fetch; not persisted in ent DB. - repeated GlobalRole roles = 10; - google.protobuf.Timestamp modified_at = 11; + repeated GlobalRole roles = 7; + google.protobuf.Timestamp modified_at = 8; + + // User accounts links. + string renku_url = 9; + string github_url = 10; + string linkedin_url = 11; } diff --git a/components/backend/internal/proto/user/entities/user.pb.go b/components/backend/internal/proto/user/entities/user.pb.go index c76e9e83..7a69d882 100644 --- a/components/backend/internal/proto/user/entities/user.pb.go +++ b/components/backend/internal/proto/user/entities/user.pb.go @@ -30,12 +30,13 @@ type User struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` Email string `protobuf:"bytes,6,opt,name=email,proto3" json:"email,omitempty"` - RenkuUrl string `protobuf:"bytes,7,opt,name=renku_url,json=renkuUrl,proto3" json:"renku_url,omitempty"` - GithubUrl string `protobuf:"bytes,8,opt,name=github_url,json=githubUrl,proto3" json:"github_url,omitempty"` - LinkedinUrl string `protobuf:"bytes,9,opt,name=linkedin_url,json=linkedinUrl,proto3" json:"linkedin_url,omitempty"` // Populated from casbin on fetch; not persisted in ent DB. - Roles []GlobalRole `protobuf:"varint,10,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` - ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + Roles []GlobalRole `protobuf:"varint,7,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` + ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + // User accounts links. + RenkuUrl string `protobuf:"bytes,9,opt,name=renku_url,json=renkuUrl,proto3" json:"renku_url,omitempty"` + GithubUrl string `protobuf:"bytes,10,opt,name=github_url,json=githubUrl,proto3" json:"github_url,omitempty"` + LinkedinUrl string `protobuf:"bytes,11,opt,name=linkedin_url,json=linkedinUrl,proto3" json:"linkedin_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -112,39 +113,39 @@ func (x *User) GetEmail() string { return "" } -func (x *User) GetRenkuUrl() string { +func (x *User) GetRoles() []GlobalRole { if x != nil { - return x.RenkuUrl + return x.Roles } - return "" + return nil } -func (x *User) GetGithubUrl() string { +func (x *User) GetModifiedAt() *timestamppb.Timestamp { if x != nil { - return x.GithubUrl + return x.ModifiedAt } - return "" + return nil } -func (x *User) GetLinkedinUrl() string { +func (x *User) GetRenkuUrl() string { if x != nil { - return x.LinkedinUrl + return x.RenkuUrl } return "" } -func (x *User) GetRoles() []GlobalRole { +func (x *User) GetGithubUrl() string { if x != nil { - return x.Roles + return x.GithubUrl } - return nil + return "" } -func (x *User) GetModifiedAt() *timestamppb.Timestamp { +func (x *User) GetLinkedinUrl() string { if x != nil { - return x.ModifiedAt + return x.LinkedinUrl } - return nil + return "" } var File_user_entities_user_proto protoreflect.FileDescriptor @@ -160,15 +161,15 @@ const file_user_entities_user_proto_rawDesc = "" + "\n" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" + "\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x06 \x01(\tR\x05email\x12\x1b\n" + - "\trenku_url\x18\a \x01(\tR\brenkuUrl\x12\x1d\n" + + "\x05email\x18\x06 \x01(\tR\x05email\x12/\n" + + "\x05roles\x18\a \x03(\x0e2\x19.user.entities.GlobalRoleR\x05roles\x12;\n" + + "\vmodified_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "modifiedAt\x12\x1b\n" + + "\trenku_url\x18\t \x01(\tR\brenkuUrl\x12\x1d\n" + "\n" + - "github_url\x18\b \x01(\tR\tgithubUrl\x12!\n" + - "\flinkedin_url\x18\t \x01(\tR\vlinkedinUrl\x12/\n" + - "\x05roles\x18\n" + - " \x03(\x0e2\x19.user.entities.GlobalRoleR\x05roles\x12;\n" + - "\vmodified_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "modifiedAtB\\ZZgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entitiesb\x06proto3" + "github_url\x18\n" + + " \x01(\tR\tgithubUrl\x12!\n" + + "\flinkedin_url\x18\v \x01(\tR\vlinkedinUrlB\\ZZgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entitiesb\x06proto3" var ( file_user_entities_user_proto_rawDescOnce sync.Once diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 70c09fd4..6b6e3eee 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/url" "regexp" + "strings" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" "google.golang.org/grpc/codes" @@ -17,35 +18,50 @@ const ( RenkuPrefix = "https://renkulab.io/u/" ) +// Accept what users actually paste: http(s), any host case, an optional +// subdomain (www., LinkedIn country codes like ch.), a trailing slash and +// a query string or fragment. var ( - linkedInRe = regexp.MustCompile(`^https://linkedin\.com/in/([A-Za-z0-9-]{3,100})/?$`) - githubRe = regexp.MustCompile(`^https://github\.com/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)/?$`) - renkuRe = regexp.MustCompile( - `^https://renkulab\.io/u/([A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)/?$`, + linkedInRe = regexp.MustCompile( + `(?i)^https?://(?:[a-z]{2,3}\.)?linkedin\.com/in/([\p{L}\p{N}-]{3,100})/?(?:[?#].*)?$`, + ) + githubRe = regexp.MustCompile( + `(?i)^https?://(?:www\.)?github\.com/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)/?(?:[?#].*)?$`, + ) + renkuRe = regexp.MustCompile( + `(?i)^https?://(?:www\.)?renkulab\.io/u/([A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)/?(?:[?#].*)?$`, ) ) -func linkedInUsername(url string) (string, error) { - m := linkedInRe.FindStringSubmatch(url) +func linkedInUsername(link string) (string, error) { + link = strings.TrimSpace(link) + + if s, err := url.PathUnescape(link); err == nil { + link = s + } + + m := linkedInRe.FindStringSubmatch(link) if m == nil { - return "", fmt.Errorf("url '%s' does not match regexp", url) + return "", fmt.Errorf("url '%s' does not match regexp", link) } return m[1], nil } -func githubUsername(url string) (string, error) { - m := githubRe.FindStringSubmatch(url) +func githubUsername(link string) (string, error) { + link = strings.TrimSpace(link) + m := githubRe.FindStringSubmatch(link) if m == nil || len(m[1]) > 39 { - return "", fmt.Errorf("url '%s' does not match regexp", url) + return "", fmt.Errorf("url '%s' does not match regexp", link) } return m[1], nil } -func renkuUsername(url string) (string, error) { - m := renkuRe.FindStringSubmatch(url) +func renkuUsername(link string) (string, error) { + link = strings.TrimSpace(link) + m := renkuRe.FindStringSubmatch(link) if m == nil { - return "", fmt.Errorf("url '%s' does not match regexp", url) + return "", fmt.Errorf("url '%s' does not match regexp", link) } return m[1], nil } diff --git a/components/backend/internal/service/userlinks_test.go b/components/backend/internal/service/userlinks_test.go new file mode 100644 index 00000000..4d0a8a87 --- /dev/null +++ b/components/backend/internal/service/userlinks_test.go @@ -0,0 +1,42 @@ +//go:build test && unittest + +package service + +import "testing" + +func TestLinkUsernames(t *testing.T) { + cases := []struct { + parse func(string) (string, error) + in string + want string // "" means the URL must be rejected + }{ + {linkedInUsername, "https://linkedin.com/in/jane-doe", "jane-doe"}, + {linkedInUsername, "https://www.linkedin.com/in/jane-doe/", "jane-doe"}, + {linkedInUsername, "https://ch.linkedin.com/in/jane-doe/?originalSubdomain=ch", "jane-doe"}, + {linkedInUsername, " HTTP://WWW.LinkedIn.com/in/jane-doe#about ", "jane-doe"}, + {linkedInUsername, "https://www.linkedin.com/in/j%C3%B6rg-m%C3%BCller/", "jörg-müller"}, + {linkedInUsername, "https://www.linkedin.com/in/jörg-müller", "jörg-müller"}, + {linkedInUsername, "https://evil.com/linkedin.com/in/jane", ""}, + {linkedInUsername, "https://www.linkedin.com/company/sdsc", ""}, + {linkedInUsername, "https://www.linkedin.com/in/jane/details", ""}, + {githubUsername, "https://github.com/octo-cat", "octo-cat"}, + {githubUsername, "https://www.github.com/octo-cat/?tab=repositories", "octo-cat"}, + {githubUsername, "http://GitHub.com/octo-cat", "octo-cat"}, + {githubUsername, "https://github.com/octo-cat/repo", ""}, + {githubUsername, "https://github.com/-bad", ""}, + {githubUsername, "https://notgithub.com/octo-cat", ""}, + {renkuUsername, "https://renkulab.io/u/jane.doe", "jane.doe"}, + {renkuUsername, "https://renkulab.io/u/jane.doe/?x=1", "jane.doe"}, + {renkuUsername, "https://renkulab.io/u/jane/projects", ""}, + } + for _, c := range cases { + got, err := c.parse(c.in) + if c.want == "" { + if err == nil { + t.Errorf("%q: want error, got %q", c.in, got) + } + } else if err != nil || got != c.want { + t.Errorf("%q: want %q, got %q (err %v)", c.in, c.want, got, err) + } + } +} diff --git a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts index de703adc..33c58471 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts @@ -18,12 +18,15 @@ export interface User { createdAt: Date | undefined; displayName: string; email: string; + /** Populated from casbin on fetch; not persisted in ent DB. */ + roles: GlobalRole[]; + modifiedAt: + | Date + | undefined; + /** User accounts links. */ renkuUrl: string; githubUrl: string; linkedinUrl: string; - /** Populated from casbin on fetch; not persisted in ent DB. */ - roles: GlobalRole[]; - modifiedAt: Date | undefined; } function createBaseUser(): User { @@ -34,11 +37,11 @@ function createBaseUser(): User { createdAt: undefined, displayName: "", email: "", + roles: [], + modifiedAt: undefined, renkuUrl: "", githubUrl: "", linkedinUrl: "", - roles: [], - modifiedAt: undefined, }; } @@ -62,22 +65,22 @@ export const User: MessageFns = { if (message.email !== "") { writer.uint32(50).string(message.email); } - if (message.renkuUrl !== "") { - writer.uint32(58).string(message.renkuUrl); - } - if (message.githubUrl !== "") { - writer.uint32(66).string(message.githubUrl); - } - if (message.linkedinUrl !== "") { - writer.uint32(74).string(message.linkedinUrl); - } - writer.uint32(82).fork(); + writer.uint32(58).fork(); for (const v of message.roles) { writer.int32(v); } writer.join(); if (message.modifiedAt !== undefined) { - Timestamp.encode(toTimestamp(message.modifiedAt), writer.uint32(90).fork()).join(); + Timestamp.encode(toTimestamp(message.modifiedAt), writer.uint32(66).fork()).join(); + } + if (message.renkuUrl !== "") { + writer.uint32(74).string(message.renkuUrl); + } + if (message.githubUrl !== "") { + writer.uint32(82).string(message.githubUrl); + } + if (message.linkedinUrl !== "") { + writer.uint32(90).string(message.linkedinUrl); } return writer; }, @@ -138,19 +141,29 @@ export const User: MessageFns = { continue; } case 7: { - if (tag !== 58) { - break; + if (tag === 56) { + message.roles.push(reader.int32() as any); + + continue; } - message.renkuUrl = reader.string(); - continue; + if (tag === 58) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.roles.push(reader.int32() as any); + } + + continue; + } + + break; } case 8: { if (tag !== 66) { break; } - message.githubUrl = reader.string(); + message.modifiedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); continue; } case 9: { @@ -158,33 +171,23 @@ export const User: MessageFns = { break; } - message.linkedinUrl = reader.string(); + message.renkuUrl = reader.string(); continue; } case 10: { - if (tag === 80) { - message.roles.push(reader.int32() as any); - - continue; - } - - if (tag === 82) { - const end2 = reader.uint32() + reader.pos; - while (reader.pos < end2) { - message.roles.push(reader.int32() as any); - } - - continue; + if (tag !== 82) { + break; } - break; + message.githubUrl = reader.string(); + continue; } case 11: { if (tag !== 90) { break; } - message.modifiedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + message.linkedinUrl = reader.string(); continue; } } @@ -216,6 +219,12 @@ export const User: MessageFns = { ? globalThis.String(object.display_name) : "", email: isSet(object.email) ? globalThis.String(object.email) : "", + roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalRoleFromJSON(e)) : [], + modifiedAt: isSet(object.modifiedAt) + ? fromJsonTimestamp(object.modifiedAt) + : isSet(object.modified_at) + ? fromJsonTimestamp(object.modified_at) + : undefined, renkuUrl: isSet(object.renkuUrl) ? globalThis.String(object.renkuUrl) : isSet(object.renku_url) @@ -231,14 +240,6 @@ export const User: MessageFns = { : isSet(object.linkedin_url) ? globalThis.String(object.linkedin_url) : "", - roles: globalThis.Array.isArray(object?.roles) - ? object.roles.map((e: any) => globalRoleFromJSON(e)) - : [], - modifiedAt: isSet(object.modifiedAt) - ? fromJsonTimestamp(object.modifiedAt) - : isSet(object.modified_at) - ? fromJsonTimestamp(object.modified_at) - : undefined, }; }, @@ -262,6 +263,12 @@ export const User: MessageFns = { if (message.email !== "") { obj.email = message.email; } + if (message.roles?.length) { + obj.roles = message.roles.map((e) => globalRoleToJSON(e)); + } + if (message.modifiedAt !== undefined) { + obj.modifiedAt = message.modifiedAt.toISOString(); + } if (message.renkuUrl !== "") { obj.renkuUrl = message.renkuUrl; } @@ -271,12 +278,6 @@ export const User: MessageFns = { if (message.linkedinUrl !== "") { obj.linkedinUrl = message.linkedinUrl; } - if (message.roles?.length) { - obj.roles = message.roles.map((e) => globalRoleToJSON(e)); - } - if (message.modifiedAt !== undefined) { - obj.modifiedAt = message.modifiedAt.toISOString(); - } return obj; }, @@ -291,11 +292,11 @@ export const User: MessageFns = { message.createdAt = object.createdAt ?? undefined; message.displayName = object.displayName ?? ""; message.email = object.email ?? ""; + message.roles = object.roles?.map((e) => e) || []; + message.modifiedAt = object.modifiedAt ?? undefined; message.renkuUrl = object.renkuUrl ?? ""; message.githubUrl = object.githubUrl ?? ""; message.linkedinUrl = object.linkedinUrl ?? ""; - message.roles = object.roles?.map((e) => e) || []; - message.modifiedAt = object.modifiedAt ?? undefined; return message; }, }; From 4f509e22ef10f937c102254feaebd828d8fc3e8a Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 14:05:06 +0200 Subject: [PATCH 05/23] chore: improve code factorization; improve regexps --- .../backend/internal/service/userlinks.go | 32 +++++++------------ .../internal/service/userlinks_test.go | 12 ++++++- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 6b6e3eee..38dd7bb4 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -18,51 +18,43 @@ const ( RenkuPrefix = "https://renkulab.io/u/" ) -// Accept what users actually paste: http(s), any host case, an optional -// subdomain (www., LinkedIn country codes like ch.), a trailing slash and -// a query string or fragment. var ( linkedInRe = regexp.MustCompile( `(?i)^https?://(?:[a-z]{2,3}\.)?linkedin\.com/in/([\p{L}\p{N}-]{3,100})/?(?:[?#].*)?$`, ) githubRe = regexp.MustCompile( - `(?i)^https?://(?:www\.)?github\.com/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)/?(?:[?#].*)?$`, + `(?i)^https?://(?:www\.)?github\.com/([A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?)/?(?:[?#].*)?$`, ) renkuRe = regexp.MustCompile( - `(?i)^https?://(?:www\.)?renkulab\.io/u/([A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)/?(?:[?#].*)?$`, + `(?i)^https?://(?:www\.)?renkulab\.io/u/([A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?)/?(?:[?#].*)?$`, ) ) func linkedInUsername(link string) (string, error) { - link = strings.TrimSpace(link) - + // We need to PathUnescape because LinkedIn allows some non-ASCII characters for usernames. if s, err := url.PathUnescape(link); err == nil { link = s } - m := linkedInRe.FindStringSubmatch(link) - if m == nil { - return "", fmt.Errorf("url '%s' does not match regexp", link) - } - - return m[1], nil + return parseLink(linkedInRe, link) } func githubUsername(link string) (string, error) { - link = strings.TrimSpace(link) - m := githubRe.FindStringSubmatch(link) - if m == nil || len(m[1]) > 39 { - return "", fmt.Errorf("url '%s' does not match regexp", link) - } - return m[1], nil + return parseLink(githubRe, link) } func renkuUsername(link string) (string, error) { + return parseLink(renkuRe, link) +} + +func parseLink(re *regexp.Regexp, link string) (string, error) { link = strings.TrimSpace(link) - m := renkuRe.FindStringSubmatch(link) + + m := re.FindStringSubmatch(link) if m == nil { return "", fmt.Errorf("url '%s' does not match regexp", link) } + return m[1], nil } diff --git a/components/backend/internal/service/userlinks_test.go b/components/backend/internal/service/userlinks_test.go index 4d0a8a87..8c5662c0 100644 --- a/components/backend/internal/service/userlinks_test.go +++ b/components/backend/internal/service/userlinks_test.go @@ -2,7 +2,10 @@ package service -import "testing" +import ( + "strings" + "testing" +) func TestLinkUsernames(t *testing.T) { cases := []struct { @@ -25,9 +28,16 @@ func TestLinkUsernames(t *testing.T) { {githubUsername, "https://github.com/octo-cat/repo", ""}, {githubUsername, "https://github.com/-bad", ""}, {githubUsername, "https://notgithub.com/octo-cat", ""}, + {githubUsername, "https://github.com/bad-", ""}, + {githubUsername, "https://github.com/a", "a"}, + {githubUsername, "https://github.com/" + strings.Repeat("a", 100), strings.Repeat("a", 100)}, + {githubUsername, "https://github.com/" + strings.Repeat("a", 101), ""}, {renkuUsername, "https://renkulab.io/u/jane.doe", "jane.doe"}, {renkuUsername, "https://renkulab.io/u/jane.doe/?x=1", "jane.doe"}, {renkuUsername, "https://renkulab.io/u/jane/projects", ""}, + {renkuUsername, "https://renkulab.io/u/.jane", ""}, + {renkuUsername, "https://renkulab.io/u/" + strings.Repeat("a", 100), strings.Repeat("a", 100)}, + {renkuUsername, "https://renkulab.io/u/" + strings.Repeat("a", 101), ""}, } for _, c := range cases { got, err := c.parse(c.in) From ca1f6f250eb360046a196d110415763021cbf886 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 14:55:01 +0200 Subject: [PATCH 06/23] feat: let users edit their own profile --- .../backend/internal/service/user_service.go | 15 ++++++++++++++- .../backend/internal/service/user_service_test.go | 6 ++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 8a05423f..e368ce37 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -65,16 +65,29 @@ func (s *UserService) Edit( ctx context.Context, req *msgs.EditRequest, ) (*msgs.EditResponse, error) { - if err := s.enforcer.RequirePermission(ctx, "", m.User, m.Write); err != nil { + sub, _, err := m.RequireSubject(ctx) + if err != nil { return nil, err } + isAdmin, err := s.enforcer.Enforce(ctx, "", m.User, m.Write) + if err != nil { + slog.Error("Could not enforce permission.", "error", err.Error()) + + return nil, status.Errorf(codes.Internal, "internal server error") + } + id, err := uuid.Parse(req.GetUserId()) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) } updateQuery := s.dbClient.User.UpdateOneID(id) + if !isAdmin { + // Non-admins can only edit their own user. + updateQuery.Where(entuser.KeycloakIDEQ(sub)) + } + updateQuery, err = updateLinkedin(updateQuery, req.LinkedinUrl) if err != nil { return nil, err diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 45c5db3f..1a87c1f3 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -869,10 +869,13 @@ var _ = Describe("UserService", func() { newGithub := "https://github.com/bob-github-new" newLinkedin := "https://linkedin.com/in/bob-linkedin-new" + keycloakID := "new-user" + token := testutils.CreateTestJWTToken(keycloakID) + BeforeEach(func() { var err error targetUser, err = dbClient.User.Create(). - SetKeycloakID("get-target"). + SetKeycloakID(keycloakID). SetUsername("get-target-username"). SetDisplayName("Get Target"). SetEmail("get@example.com"). @@ -884,7 +887,6 @@ var _ = Describe("UserService", func() { }) It("correctly changes the links values", func() { - token := testutils.CreateTestJWTToken(testAdmin) ctx := metadata.NewOutgoingContext( context.Background(), metadata.Pairs("authorization", "Bearer "+token), From 2c83e72457c036905afc75e2c01a7aef2493afab Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 15:02:06 +0200 Subject: [PATCH 07/23] chore: factorize userWithRoles --- .../backend/internal/service/user_service.go | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index e368ce37..ed6fb8a3 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -111,14 +111,10 @@ func (s *UserService) Edit( return nil, status.Error(codes.Internal, "couldn't query database") } - globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + entry, err := s.userWithRoles(u) if err != nil { - slog.Error("get global roles", "err", err) - - return nil, status.Error(codes.Internal, "couldn't resolve user roles") + return nil, err } - entry := userEntryFromEnt(u) - entry.Roles = append(entry.Roles, globalRoles...) return &msgs.EditResponse{User: entry}, nil } @@ -143,14 +139,11 @@ func (s *UserService) Get( return nil, status.Error(codes.Internal, "couldn't query database") } - globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) - if err != nil { - slog.Error("get global roles", "err", err) - return nil, status.Error(codes.Internal, "couldn't resolve user roles") + entry, err := s.userWithRoles(u) + if err != nil { + return nil, err } - entry := userEntryFromEnt(u) - entry.Roles = append(entry.Roles, globalRoles...) return &msgs.GetResponse{User: entry}, nil } @@ -190,14 +183,10 @@ func (s *UserService) WhoAmI( } } - globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + entry, err := s.userWithRoles(u) if err != nil { - slog.Error("get global roles", "err", err) - - return nil, status.Error(codes.Internal, "couldn't resolve user roles") + return nil, err } - entry := userEntryFromEnt(u) - entry.Roles = append(entry.Roles, globalRoles...) return &msgs.WhoAmIResponse{User: entry}, nil } @@ -239,14 +228,10 @@ func (s *UserService) AddRole( return nil, status.Error(codes.Internal, "couldn't assign role") } - globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + entry, err := s.userWithRoles(u) if err != nil { - slog.Error("get global roles", "err", err) - - return nil, status.Error(codes.Internal, "couldn't resolve user roles") + return nil, err } - entry := userEntryFromEnt(u) - entry.Roles = append(entry.Roles, globalRoles...) return &msgs.AddRoleResponse{User: entry}, nil } @@ -294,14 +279,10 @@ func (s *UserService) RemoveRole( return nil, status.Errorf(codes.Internal, "couldn't remove user roles: %v", err) } - globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + entry, err := s.userWithRoles(u) if err != nil { - slog.Error("get global roles", "err", err) - - return nil, status.Error(codes.Internal, "couldn't resolve user roles") + return nil, err } - entry := userEntryFromEnt(u) - entry.Roles = append(entry.Roles, globalRoles...) return &msgs.RemoveRoleResponse{User: entry}, nil } @@ -370,3 +351,16 @@ func (s *UserService) Register( return &msgs.RegisterResponse{User: userEntryFromEnt(u)}, nil } + +func (s *UserService) userWithRoles(u *ent.User) (*ents.User, error) { + globalRoles, err := s.enforcer.GetGlobalRoles(u.KeycloakID) + if err != nil { + slog.Error("get global roles", "err", err) + + return nil, status.Error(codes.Internal, "couldn't resolve user roles") + } + entry := userEntryFromEnt(u) + entry.Roles = append(entry.Roles, globalRoles...) + + return entry, nil +} From 2c4f5e4b085c2ec56e05b4fdd33da9191cc012bf Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 15:17:07 +0200 Subject: [PATCH 08/23] feat: add uuid constraint for user id in proto --- api/proto/user/messages/user_svc/add_role_request.proto | 3 ++- api/proto/user/messages/user_svc/edit_request.proto | 4 +++- api/proto/user/messages/user_svc/get_request.proto | 4 +++- api/proto/user/messages/user_svc/remove_role_request.proto | 3 ++- .../proto/user/messages/user_svc/add_role_request.pb.go | 7 ++++--- .../proto/user/messages/user_svc/edit_request.pb.go | 7 ++++--- .../proto/user/messages/user_svc/get_request.pb.go | 7 ++++--- .../proto/user/messages/user_svc/remove_role_request.pb.go | 7 ++++--- 8 files changed, 26 insertions(+), 16 deletions(-) diff --git a/api/proto/user/messages/user_svc/add_role_request.proto b/api/proto/user/messages/user_svc/add_role_request.proto index 34e07b2f..f3fc066f 100644 --- a/api/proto/user/messages/user_svc/add_role_request.proto +++ b/api/proto/user/messages/user_svc/add_role_request.proto @@ -3,10 +3,11 @@ syntax = "proto3"; package user.messages.user_svc; import "user/entities/global_role.proto"; +import "buf/validate/validate.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; message AddRoleRequest { - string user_id = 1; + string user_id = 1 [(buf.validate.field).string.uuid = true]; user.entities.GlobalRole role = 2; } diff --git a/api/proto/user/messages/user_svc/edit_request.proto b/api/proto/user/messages/user_svc/edit_request.proto index 2502db77..0d1f90bf 100644 --- a/api/proto/user/messages/user_svc/edit_request.proto +++ b/api/proto/user/messages/user_svc/edit_request.proto @@ -2,10 +2,12 @@ syntax = "proto3"; package user.messages.user_svc; +import "buf/validate/validate.proto"; + option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; message EditRequest { - string user_id = 1; + string user_id = 1 [(buf.validate.field).string.uuid = true]; optional string renku_url = 2; optional string github_url = 3; optional string linkedin_url = 4; diff --git a/api/proto/user/messages/user_svc/get_request.proto b/api/proto/user/messages/user_svc/get_request.proto index 41158f51..87def346 100644 --- a/api/proto/user/messages/user_svc/get_request.proto +++ b/api/proto/user/messages/user_svc/get_request.proto @@ -2,8 +2,10 @@ syntax = "proto3"; package user.messages.user_svc; +import "buf/validate/validate.proto"; + option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; message GetRequest { - string user_id = 1; + string user_id = 1 [(buf.validate.field).string.uuid = true]; } diff --git a/api/proto/user/messages/user_svc/remove_role_request.proto b/api/proto/user/messages/user_svc/remove_role_request.proto index bf42e30d..95631364 100644 --- a/api/proto/user/messages/user_svc/remove_role_request.proto +++ b/api/proto/user/messages/user_svc/remove_role_request.proto @@ -3,10 +3,11 @@ syntax = "proto3"; package user.messages.user_svc; import "user/entities/global_role.proto"; +import "buf/validate/validate.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; message RemoveRoleRequest { - string user_id = 1; + string user_id = 1 [(buf.validate.field).string.uuid = true]; user.entities.GlobalRole role = 2; } diff --git a/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go index 5fdc6eb2..27f7dba9 100644 --- a/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go @@ -7,6 +7,7 @@ package user_svc import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" entities "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" @@ -78,9 +79,9 @@ var File_user_messages_user_svc_add_role_request_proto protoreflect.FileDescript const file_user_messages_user_svc_add_role_request_proto_rawDesc = "" + "\n" + - "-user/messages/user_svc/add_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\"X\n" + - "\x0eAddRoleRequest\x12\x17\n" + - "\auser_id\x18\x01 \x01(\tR\x06userId\x12-\n" + + "-user/messages/user_svc/add_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\x1a\x1bbuf/validate/validate.proto\"b\n" + + "\x0eAddRoleRequest\x12!\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12-\n" + "\x04role\x18\x02 \x01(\x0e2\x19.user.entities.GlobalRoleR\x04roleBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" var ( diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go index c312130b..6226dd3a 100644 --- a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go @@ -7,6 +7,7 @@ package user_svc import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -93,9 +94,9 @@ var File_user_messages_user_svc_edit_request_proto protoreflect.FileDescriptor const file_user_messages_user_svc_edit_request_proto_rawDesc = "" + "\n" + - ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\"\xc2\x01\n" + - "\vEditRequest\x12\x17\n" + - "\auser_id\x18\x01 \x01(\tR\x06userId\x12 \n" + + ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\"\xcc\x01\n" + + "\vEditRequest\x12!\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12 \n" + "\trenku_url\x18\x02 \x01(\tH\x00R\brenkuUrl\x88\x01\x01\x12\"\n" + "\n" + "github_url\x18\x03 \x01(\tH\x01R\tgithubUrl\x88\x01\x01\x12&\n" + diff --git a/components/backend/internal/proto/user/messages/user_svc/get_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/get_request.pb.go index 55ec7da1..add47b28 100644 --- a/components/backend/internal/proto/user/messages/user_svc/get_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/get_request.pb.go @@ -7,6 +7,7 @@ package user_svc import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -69,10 +70,10 @@ var File_user_messages_user_svc_get_request_proto protoreflect.FileDescriptor const file_user_messages_user_svc_get_request_proto_rawDesc = "" + "\n" + - "(user/messages/user_svc/get_request.proto\x12\x16user.messages.user_svc\"%\n" + + "(user/messages/user_svc/get_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\"/\n" + "\n" + - "GetRequest\x12\x17\n" + - "\auser_id\x18\x01 \x01(\tR\x06userIdBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" + "GetRequest\x12!\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userIdBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" var ( file_user_messages_user_svc_get_request_proto_rawDescOnce sync.Once diff --git a/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go index d606ae10..059e2098 100644 --- a/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go @@ -7,6 +7,7 @@ package user_svc import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" entities "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" @@ -78,9 +79,9 @@ var File_user_messages_user_svc_remove_role_request_proto protoreflect.FileDescr const file_user_messages_user_svc_remove_role_request_proto_rawDesc = "" + "\n" + - "0user/messages/user_svc/remove_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\"[\n" + - "\x11RemoveRoleRequest\x12\x17\n" + - "\auser_id\x18\x01 \x01(\tR\x06userId\x12-\n" + + "0user/messages/user_svc/remove_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\x1a\x1bbuf/validate/validate.proto\"e\n" + + "\x11RemoveRoleRequest\x12!\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12-\n" + "\x04role\x18\x02 \x01(\x0e2\x19.user.entities.GlobalRoleR\x04roleBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" var ( From c7f55545097a9959dcda07d91601493febc6b033 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 15:34:34 +0200 Subject: [PATCH 09/23] test: add tests for edit service --- .../internal/service/user_service_test.go | 101 +++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 1a87c1f3..2276edef 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -865,6 +865,14 @@ var _ = Describe("UserService", func() { var targetUser *ent.User + oldRenkuUsr := "bob-renku" + oldLinkedinUsr := "bob-linkedin" + oldGithubUsr := "bob-github" + + oldRenku := "https://renkulab.io/u/" + oldRenkuUsr + oldGithub := "https://github.com/" + oldGithubUsr + oldLinkedin := "https://linkedin.com/in/" + oldLinkedinUsr + newRenku := "https://renkulab.io/u/bob-renku-new" newGithub := "https://github.com/bob-github-new" newLinkedin := "https://linkedin.com/in/bob-linkedin-new" @@ -879,9 +887,9 @@ var _ = Describe("UserService", func() { SetUsername("get-target-username"). SetDisplayName("Get Target"). SetEmail("get@example.com"). - SetRenku("bob-renku"). - SetLinkedin("bob-linkedin"). - SetGithub("bob-github"). + SetRenku(oldRenkuUsr). + SetLinkedin(oldLinkedinUsr). + SetGithub(oldGithubUsr). Save(context.Background()) Expect(err).NotTo(HaveOccurred()) }) @@ -907,5 +915,92 @@ var _ = Describe("UserService", func() { Expect(u.GetGithubUrl()).To(Equal(newGithub)) Expect(u.GetLinkedinUrl()).To(Equal(newLinkedin)) }) + + It("lets admins edit users profiles", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + resp, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + RenkuUrl: &newRenku, + GithubUrl: &newGithub, + LinkedinUrl: &newLinkedin, + }) + + Expect(err).NotTo(HaveOccurred()) + + u := resp.GetUser() + + Expect(u.GetRenkuUrl()).To(Equal(newRenku)) + Expect(u.GetGithubUrl()).To(Equal(newGithub)) + Expect(u.GetLinkedinUrl()).To(Equal(newLinkedin)) + }) + + It("doesn't let users edit another user's profile", func() { + token := testutils.CreateTestJWTToken("another-user") + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + RenkuUrl: &newRenku, + GithubUrl: &newGithub, + LinkedinUrl: &newLinkedin, + }) + + Expect(err).To(HaveOccurred()) + }) + + It("removes empty string params but ignores nil params", func() { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + inputMatrix := []struct { + renku *string + github *string + linkedin *string + }{ + {renku: nil, github: nil, linkedin: nil}, + {renku: &newRenku, github: nil, linkedin: nil}, + {renku: nil, github: &newGithub, linkedin: nil}, + {renku: nil, github: nil, linkedin: &newLinkedin}, + } + + expectedMatrix := []struct { + renku string + github string + linkedin string + }{ + {renku: oldRenku, github: oldGithub, linkedin: oldLinkedin}, + {renku: newRenku, github: oldGithub, linkedin: oldLinkedin}, + {renku: newRenku, github: newGithub, linkedin: oldLinkedin}, + {renku: newRenku, github: newGithub, linkedin: newLinkedin}, + } + + for i := range inputMatrix { + resp, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + RenkuUrl: inputMatrix[i].renku, + GithubUrl: inputMatrix[i].github, + LinkedinUrl: inputMatrix[i].linkedin, + }) + + Expect(err).NotTo(HaveOccurred()) + + u := resp.GetUser() + + Expect(u.GetRenkuUrl()).To(Equal(expectedMatrix[i].renku)) + Expect(u.GetGithubUrl()).To(Equal(expectedMatrix[i].github)) + Expect(u.GetLinkedinUrl()).To(Equal(expectedMatrix[i].linkedin)) + } + }) }) }) From 84c50baf00aa7493f567d1e0e633af9fdcfe0d84 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 15:56:25 +0200 Subject: [PATCH 10/23] chore: refactor tests --- .../internal/service/user_service_test.go | 88 +++++++++++-------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 2276edef..8d7a80de 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -953,54 +953,70 @@ var _ = Describe("UserService", func() { LinkedinUrl: &newLinkedin, }) - Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + + u, err := dbClient.User.Get(context.Background(), targetUser.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(u.Renku).To(Equal(oldRenkuUsr)) + Expect(u.Github).To(Equal(oldGithubUsr)) + Expect(u.Linkedin).To(Equal(oldLinkedinUsr)) }) - It("removes empty string params but ignores nil params", func() { - token := testutils.CreateTestJWTToken(testAdmin) - ctx := metadata.NewOutgoingContext( - context.Background(), - metadata.Pairs("authorization", "Bearer "+token), - ) + type input struct { + renku *string + github *string + linkedin *string + } - inputMatrix := []struct { - renku *string - github *string - linkedin *string - }{ - {renku: nil, github: nil, linkedin: nil}, - {renku: &newRenku, github: nil, linkedin: nil}, - {renku: nil, github: &newGithub, linkedin: nil}, - {renku: nil, github: nil, linkedin: &newLinkedin}, - } + type expected struct { + renku string + github string + linkedin string + } - expectedMatrix := []struct { - renku string - github string - linkedin string - }{ - {renku: oldRenku, github: oldGithub, linkedin: oldLinkedin}, - {renku: newRenku, github: oldGithub, linkedin: oldLinkedin}, - {renku: newRenku, github: newGithub, linkedin: oldLinkedin}, - {renku: newRenku, github: newGithub, linkedin: newLinkedin}, - } + emptyString := "" + + DescribeTable( + "removes empty string params but ignores nil params", + func(in input, want expected) { + token := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) - for i := range inputMatrix { resp, err := userClient.Edit(ctx, &msgs.EditRequest{ UserId: targetUser.ID.String(), - RenkuUrl: inputMatrix[i].renku, - GithubUrl: inputMatrix[i].github, - LinkedinUrl: inputMatrix[i].linkedin, + RenkuUrl: in.renku, + GithubUrl: in.github, + LinkedinUrl: in.linkedin, }) Expect(err).NotTo(HaveOccurred()) u := resp.GetUser() - Expect(u.GetRenkuUrl()).To(Equal(expectedMatrix[i].renku)) - Expect(u.GetGithubUrl()).To(Equal(expectedMatrix[i].github)) - Expect(u.GetLinkedinUrl()).To(Equal(expectedMatrix[i].linkedin)) - } - }) + Expect(u.GetRenkuUrl()).To(Equal(want.renku)) + Expect(u.GetGithubUrl()).To(Equal(want.github)) + Expect(u.GetLinkedinUrl()).To(Equal(want.linkedin)) + }, + Entry("all nil", input{}, expected{oldRenku, oldGithub, oldLinkedin}), + Entry("clears renku", input{renku: &emptyString}, expected{"", oldGithub, oldLinkedin}), + Entry( + "clears github", + input{github: &emptyString}, + expected{oldRenku, "", oldLinkedin}, + ), + Entry( + "clears linkedin", + input{linkedin: &emptyString}, + expected{oldRenku, oldGithub, ""}, + ), + Entry("sets, clears and keeps in one request", + input{renku: &newRenku, github: &emptyString}, + expected{newRenku, "", oldLinkedin}, + ), + ) + }) }) From 21a49e464dab11fad4359073cb9f5baa616ceff3 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 16:07:40 +0200 Subject: [PATCH 11/23] chore: simplify userlinks; fix logging; --- .../backend/internal/service/user_service.go | 2 +- .../backend/internal/service/userlinks.go | 24 +------------------ .../internal/service/userlinks_test.go | 12 ++++++++++ 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index ed6fb8a3..800e003a 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -85,7 +85,7 @@ func (s *UserService) Edit( updateQuery := s.dbClient.User.UpdateOneID(id) if !isAdmin { // Non-admins can only edit their own user. - updateQuery.Where(entuser.KeycloakIDEQ(sub)) + updateQuery = updateQuery.Where(entuser.KeycloakIDEQ(sub)) } updateQuery, err = updateLinkedin(updateQuery, req.LinkedinUrl) diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 38dd7bb4..6071f043 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -2,7 +2,6 @@ package service import ( "fmt" - "log/slog" "net/url" "regexp" "strings" @@ -69,8 +68,6 @@ func updateLinkedin(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdat } else if u, err := linkedInUsername(*req); err == nil { username = u } else { - slog.Error("Couldn't get LinkedIn username.", "error", err.Error()) - return nil, status.Errorf( codes.InvalidArgument, "invalid LinkedIn URL '%s'", @@ -92,8 +89,6 @@ func updateGithub(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateO } else if u, err := githubUsername(*req); err == nil { username = u } else { - slog.Error("Couldn't get GitHub username.", "error", err.Error()) - return nil, status.Errorf( codes.InvalidArgument, "invalid GitHub URL '%s'", @@ -115,8 +110,6 @@ func updateRenku(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOn } else if u, err := renkuUsername(*req); err == nil { username = u } else { - slog.Error("Couldn't get Renku username.", "error", err.Error()) - return nil, status.Errorf( codes.InvalidArgument, "invalid Renku URL '%s'", @@ -144,20 +137,5 @@ func renderLink(prefix, username string) string { return "" } - u, err := url.JoinPath(prefix, username) - if err != nil { - slog.Error( - "Could not create link URL.", - "error", - err.Error(), - "prefix", - prefix, - "username", - username, - ) - - return "" - } - - return u + return prefix + url.PathEscape(username) } diff --git a/components/backend/internal/service/userlinks_test.go b/components/backend/internal/service/userlinks_test.go index 8c5662c0..68b2c7dc 100644 --- a/components/backend/internal/service/userlinks_test.go +++ b/components/backend/internal/service/userlinks_test.go @@ -50,3 +50,15 @@ func TestLinkUsernames(t *testing.T) { } } } + +func TestRenderLink(t *testing.T) { + if got := renderLinkedinURL("jörg-müller"); got != "https://linkedin.com/in/j%C3%B6rg-m%C3%BCller" { + t.Errorf("got %q", got) + } + if got := renderRenkuURL("jane.doe_1"); got != "https://renkulab.io/u/jane.doe_1" { + t.Errorf("got %q", got) + } + if got := renderGithubURL(""); got != "" { + t.Errorf("got %q", got) + } +} From e6f6950840bf28f84cee2e79de5c204701b01303 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 16:17:24 +0200 Subject: [PATCH 12/23] fix: reject anonymous users in edit --- components/backend/internal/service/user_service.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 800e003a..5a750a28 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -70,6 +70,10 @@ func (s *UserService) Edit( return nil, err } + if sub == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "anonymous users cannot edit profiles") + } + isAdmin, err := s.enforcer.Enforce(ctx, "", m.User, m.Write) if err != nil { slog.Error("Could not enforce permission.", "error", err.Error()) From bd40703a3c77c732f7e99749338f25332f891556 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 16:17:30 +0200 Subject: [PATCH 13/23] test: add more tests --- .../internal/service/user_service_test.go | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 8d7a80de..fc75a201 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -962,6 +962,67 @@ var _ = Describe("UserService", func() { Expect(u.Linkedin).To(Equal(oldLinkedinUsr)) }) + It("rejects an invalid URL without writing any link", func() { + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + badLinkedin := "https://example.com/in/bob" + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + GithubUrl: &newGithub, + LinkedinUrl: &badLinkedin, + }) + + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + + // The valid github_url must not be saved either. + u, err := dbClient.User.Get(context.Background(), targetUser.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(u.Renku).To(Equal(oldRenkuUsr)) + Expect(u.Github).To(Equal(oldGithubUsr)) + Expect(u.Linkedin).To(Equal(oldLinkedinUsr)) + }) + + It("rejects a malformed user_id", func() { + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: "not-a-uuid", + GithubUrl: &newGithub, + }) + + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + + It("rejects anonymous requests", func() { + _, err := userClient.Edit(context.Background(), &msgs.EditRequest{ + UserId: targetUser.ID.String(), + GithubUrl: &newGithub, + }) + + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + }) + + It("returns NotFound for a non-existent user", func() { + adminToken := testutils.CreateTestJWTToken(testAdmin) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+adminToken), + ) + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: uuid.NewString(), + GithubUrl: &newGithub, + }) + + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + type input struct { renku *string github *string From 763f6b23b4d2bdc9c2e78c12739d39ff078c192f Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 16:32:13 +0200 Subject: [PATCH 14/23] chore: make urls 2048 chars long max; refactor code --- .../user/messages/user_svc/edit_request.proto | 6 +- .../user/messages/user_svc/edit_request.pb.go | 10 +-- .../backend/internal/service/user_service.go | 21 +++-- .../internal/service/user_service_test.go | 24 ++++- .../backend/internal/service/userlinks.go | 87 +++++-------------- 5 files changed, 61 insertions(+), 87 deletions(-) diff --git a/api/proto/user/messages/user_svc/edit_request.proto b/api/proto/user/messages/user_svc/edit_request.proto index 0d1f90bf..8c56c431 100644 --- a/api/proto/user/messages/user_svc/edit_request.proto +++ b/api/proto/user/messages/user_svc/edit_request.proto @@ -8,7 +8,7 @@ option go_package = "github.com/swissdatasciencecenter/hackagon/components/backe message EditRequest { string user_id = 1 [(buf.validate.field).string.uuid = true]; - optional string renku_url = 2; - optional string github_url = 3; - optional string linkedin_url = 4; + optional string renku_url = 2 [(buf.validate.field).string.max_len = 2048]; + optional string github_url = 3 [(buf.validate.field).string.max_len = 2048]; + optional string linkedin_url = 4 [(buf.validate.field).string.max_len = 2048]; } diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go index 6226dd3a..85a94cba 100644 --- a/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/edit_request.pb.go @@ -94,13 +94,13 @@ var File_user_messages_user_svc_edit_request_proto protoreflect.FileDescriptor const file_user_messages_user_svc_edit_request_proto_rawDesc = "" + "\n" + - ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\"\xcc\x01\n" + + ")user/messages/user_svc/edit_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\"\xea\x01\n" + "\vEditRequest\x12!\n" + - "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12 \n" + - "\trenku_url\x18\x02 \x01(\tH\x00R\brenkuUrl\x88\x01\x01\x12\"\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12*\n" + + "\trenku_url\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x10H\x00R\brenkuUrl\x88\x01\x01\x12,\n" + "\n" + - "github_url\x18\x03 \x01(\tH\x01R\tgithubUrl\x88\x01\x01\x12&\n" + - "\flinkedin_url\x18\x04 \x01(\tH\x02R\vlinkedinUrl\x88\x01\x01B\f\n" + + "github_url\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x10H\x01R\tgithubUrl\x88\x01\x01\x120\n" + + "\flinkedin_url\x18\x04 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x10H\x02R\vlinkedinUrl\x88\x01\x01B\f\n" + "\n" + "_renku_urlB\r\n" + "\v_github_urlB\x0f\n" + diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 5a750a28..c6028b0e 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -86,25 +86,28 @@ func (s *UserService) Edit( return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) } - updateQuery := s.dbClient.User.UpdateOneID(id) - if !isAdmin { - // Non-admins can only edit their own user. - updateQuery = updateQuery.Where(entuser.KeycloakIDEQ(sub)) - } - - updateQuery, err = updateLinkedin(updateQuery, req.LinkedinUrl) + linkedin, err := linkUsername(linkedInUsername, req.LinkedinUrl, "LinkedIn") if err != nil { return nil, err } - updateQuery, err = updateGithub(updateQuery, req.GithubUrl) + github, err := linkUsername(githubUsername, req.GithubUrl, "GitHub") if err != nil { return nil, err } - updateQuery, err = updateRenku(updateQuery, req.RenkuUrl) + renku, err := linkUsername(renkuUsername, req.RenkuUrl, "Renku") if err != nil { return nil, err } + updateQuery := s.dbClient.User.UpdateOneID(id). + SetNillableLinkedin(linkedin). + SetNillableGithub(github). + SetNillableRenku(renku) + if !isAdmin { + // Non-admins can only edit their own user. + updateQuery = updateQuery.Where(entuser.KeycloakIDEQ(sub)) + } + u, err := updateQuery.Save(ctx) if err != nil { if ent.IsNotFound(err) { diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index fc75a201..81dc3619 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -5,6 +5,7 @@ package service_test import ( "context" "fmt" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -20,7 +21,6 @@ import ( userSvc "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user" ents "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entities" msgs "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc" - "github.com/swissdatasciencecenter/hackagon/components/backend/internal/service" "github.com/swissdatasciencecenter/hackagon/components/backend/internal/testutils" ) @@ -75,9 +75,9 @@ var _ = Describe("UserService", func() { Expect(u.GetUsername()).To(Equal("get-target-username")) Expect(u.GetDisplayName()).To(Equal("Get Target")) Expect(u.GetEmail()).To(Equal("get@example.com")) - Expect(u.GetRenkuUrl()).To(Equal(service.RenkuPrefix + "bob-renku")) - Expect(u.GetLinkedinUrl()).To(Equal(service.LinkedInPrefix + "bob-linkedin")) - Expect(u.GetGithubUrl()).To(Equal(service.GithubPrefix + "bob-github")) + Expect(u.GetRenkuUrl()).To(Equal("https://renkulab.io/u/bob-renku")) + Expect(u.GetLinkedinUrl()).To(Equal("https://linkedin.com/in/bob-linkedin")) + Expect(u.GetGithubUrl()).To(Equal("https://github.com/bob-github")) Expect(u.GetKeycloakId()).To(Equal("get-target")) Expect(u.GetCreatedAt()).NotTo(BeNil()) Expect(u.GetModifiedAt()).NotTo(BeNil()) @@ -985,6 +985,22 @@ var _ = Describe("UserService", func() { Expect(u.Linkedin).To(Equal(oldLinkedinUsr)) }) + It("rejects a URL longer than 2048 characters", func() { + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + // The regex accepts any query string, so only max_len rejects this. + longGithub := "https://github.com/bob?" + strings.Repeat("a", 2048) + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: targetUser.ID.String(), + GithubUrl: &longGithub, + }) + + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + It("rejects a malformed user_id", func() { ctx := metadata.NewOutgoingContext( context.Background(), diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 6071f043..fdac5243 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -6,15 +6,14 @@ import ( "regexp" "strings" - "github.com/swissdatasciencecenter/hackagon/components/backend/ent" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) const ( - LinkedInPrefix = "https://linkedin.com/in/" - GithubPrefix = "https://github.com/" - RenkuPrefix = "https://renkulab.io/u/" + linkedInPrefix = "https://linkedin.com/in/" + githubPrefix = "https://github.com/" + renkuPrefix = "https://renkulab.io/u/" ) var ( @@ -22,10 +21,10 @@ var ( `(?i)^https?://(?:[a-z]{2,3}\.)?linkedin\.com/in/([\p{L}\p{N}-]{3,100})/?(?:[?#].*)?$`, ) githubRe = regexp.MustCompile( - `(?i)^https?://(?:www\.)?github\.com/([A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?)/?(?:[?#].*)?$`, + `(?i)^https?://(?:www\.)?github\.com/([a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?)/?(?:[?#].*)?$`, ) renkuRe = regexp.MustCompile( - `(?i)^https?://(?:www\.)?renkulab\.io/u/([A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?)/?(?:[?#].*)?$`, + `(?i)^https?://(?:www\.)?renkulab\.io/u/([a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?)/?(?:[?#].*)?$`, ) ) @@ -57,79 +56,35 @@ func parseLink(re *regexp.Regexp, link string) (string, error) { return m[1], nil } -func updateLinkedin(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { - if req == nil { - return updateQuery, nil +// linkUsername turns an optional link from a request into the username to +// store. +func linkUsername( + parse func(string) (string, error), + link *string, + label string, +) (*string, error) { + if link == nil || *link == "" { + return link, nil } - var username string - if *req == "" { - username = *req - } else if u, err := linkedInUsername(*req); err == nil { - username = u - } else { - return nil, status.Errorf( - codes.InvalidArgument, - "invalid LinkedIn URL '%s'", - *req, - ) + username, err := parse(*link) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid %s URL '%s'", label, *link) } - return updateQuery.SetLinkedin(username), nil -} - -func updateGithub(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { - if req == nil { - return updateQuery, nil - } - - var username string - if *req == "" { - username = *req - } else if u, err := githubUsername(*req); err == nil { - username = u - } else { - return nil, status.Errorf( - codes.InvalidArgument, - "invalid GitHub URL '%s'", - *req, - ) - } - - return updateQuery.SetGithub(username), nil -} - -func updateRenku(updateQuery *ent.UserUpdateOne, req *string) (*ent.UserUpdateOne, error) { - if req == nil { - return updateQuery, nil - } - - var username string - if *req == "" { - username = *req - } else if u, err := renkuUsername(*req); err == nil { - username = u - } else { - return nil, status.Errorf( - codes.InvalidArgument, - "invalid Renku URL '%s'", - *req, - ) - } - - return updateQuery.SetRenku(username), nil + return &username, nil } func renderLinkedinURL(username string) string { - return renderLink(LinkedInPrefix, username) + return renderLink(linkedInPrefix, username) } func renderGithubURL(username string) string { - return renderLink(GithubPrefix, username) + return renderLink(githubPrefix, username) } func renderRenkuURL(username string) string { - return renderLink(RenkuPrefix, username) + return renderLink(renkuPrefix, username) } func renderLink(prefix, username string) string { From 104f512d6253a9ea2f2b35165139cc00dcef17f6 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 16:47:51 +0200 Subject: [PATCH 15/23] chore: add comment explaining why casbin is not used for user editing their info --- components/backend/internal/service/user_service.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index c6028b0e..df4ef14b 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -105,6 +105,10 @@ func (s *UserService) Edit( SetNillableRenku(renku) if !isAdmin { // Non-admins can only edit their own user. + // NOTE: Here the ownership is only checked in the database and Casbin is only used to allow admin override. + // We do this on purpose, because using Casbin to tell whether a user can edit its info would introduce a lot + // of complexity and potential security issues (e.g., users would be able to change their own role, since + // AddRole/RemoveRole check the same User/Write permission). updateQuery = updateQuery.Where(entuser.KeycloakIDEQ(sub)) } From b99f24178f56cd1cb9ffbc156e6669ff2967e967 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 18:22:11 +0200 Subject: [PATCH 16/23] fix: lint --- components/backend/internal/service/user_service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index df4ef14b..e2dff071 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -86,14 +86,17 @@ func (s *UserService) Edit( return nil, status.Errorf(codes.InvalidArgument, "invalid user_id: %v", err) } + //nolint:protogetter // Needed because we pass an optional field. The getter wouldn't return a pointer. linkedin, err := linkUsername(linkedInUsername, req.LinkedinUrl, "LinkedIn") if err != nil { return nil, err } + //nolint:protogetter // Needed because we pass an optional field. The getter wouldn't return a pointer. github, err := linkUsername(githubUsername, req.GithubUrl, "GitHub") if err != nil { return nil, err } + //nolint:protogetter // Needed because we pass an optional field. The getter wouldn't return a pointer. renku, err := linkUsername(renkuUsername, req.RenkuUrl, "Renku") if err != nil { return nil, err From 043366e06cc0f89bf897512cac0feab07a2a4e3a Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 18:22:37 +0200 Subject: [PATCH 17/23] chore: just format --- .../messages/user_svc/add_role_request.proto | 2 +- .../messages/user_svc/remove_role_request.proto | 2 +- api/proto/user/user_service.proto | 4 ++-- .../backend/internal/service/userlinks_test.go | 16 +++++++++++++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/api/proto/user/messages/user_svc/add_role_request.proto b/api/proto/user/messages/user_svc/add_role_request.proto index f3fc066f..ed6a63a6 100644 --- a/api/proto/user/messages/user_svc/add_role_request.proto +++ b/api/proto/user/messages/user_svc/add_role_request.proto @@ -2,8 +2,8 @@ syntax = "proto3"; package user.messages.user_svc; -import "user/entities/global_role.proto"; import "buf/validate/validate.proto"; +import "user/entities/global_role.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; diff --git a/api/proto/user/messages/user_svc/remove_role_request.proto b/api/proto/user/messages/user_svc/remove_role_request.proto index 95631364..3228993f 100644 --- a/api/proto/user/messages/user_svc/remove_role_request.proto +++ b/api/proto/user/messages/user_svc/remove_role_request.proto @@ -2,8 +2,8 @@ syntax = "proto3"; package user.messages.user_svc; -import "user/entities/global_role.proto"; import "buf/validate/validate.proto"; +import "user/entities/global_role.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; diff --git a/api/proto/user/user_service.proto b/api/proto/user/user_service.proto index 4cface1a..b122599b 100644 --- a/api/proto/user/user_service.proto +++ b/api/proto/user/user_service.proto @@ -4,10 +4,10 @@ package user; import "user/messages/user_svc/add_role_request.proto"; import "user/messages/user_svc/add_role_response.proto"; -import "user/messages/user_svc/get_request.proto"; -import "user/messages/user_svc/get_response.proto"; import "user/messages/user_svc/edit_request.proto"; import "user/messages/user_svc/edit_response.proto"; +import "user/messages/user_svc/get_request.proto"; +import "user/messages/user_svc/get_response.proto"; import "user/messages/user_svc/list_request.proto"; import "user/messages/user_svc/list_response.proto"; import "user/messages/user_svc/register_request.proto"; diff --git a/components/backend/internal/service/userlinks_test.go b/components/backend/internal/service/userlinks_test.go index 68b2c7dc..61396e93 100644 --- a/components/backend/internal/service/userlinks_test.go +++ b/components/backend/internal/service/userlinks_test.go @@ -30,13 +30,21 @@ func TestLinkUsernames(t *testing.T) { {githubUsername, "https://notgithub.com/octo-cat", ""}, {githubUsername, "https://github.com/bad-", ""}, {githubUsername, "https://github.com/a", "a"}, - {githubUsername, "https://github.com/" + strings.Repeat("a", 100), strings.Repeat("a", 100)}, + { + githubUsername, + "https://github.com/" + strings.Repeat("a", 100), + strings.Repeat("a", 100), + }, {githubUsername, "https://github.com/" + strings.Repeat("a", 101), ""}, {renkuUsername, "https://renkulab.io/u/jane.doe", "jane.doe"}, {renkuUsername, "https://renkulab.io/u/jane.doe/?x=1", "jane.doe"}, {renkuUsername, "https://renkulab.io/u/jane/projects", ""}, {renkuUsername, "https://renkulab.io/u/.jane", ""}, - {renkuUsername, "https://renkulab.io/u/" + strings.Repeat("a", 100), strings.Repeat("a", 100)}, + { + renkuUsername, + "https://renkulab.io/u/" + strings.Repeat("a", 100), + strings.Repeat("a", 100), + }, {renkuUsername, "https://renkulab.io/u/" + strings.Repeat("a", 101), ""}, } for _, c := range cases { @@ -52,7 +60,9 @@ func TestLinkUsernames(t *testing.T) { } func TestRenderLink(t *testing.T) { - if got := renderLinkedinURL("jörg-müller"); got != "https://linkedin.com/in/j%C3%B6rg-m%C3%BCller" { + if got := renderLinkedinURL( + "jörg-müller", + ); got != "https://linkedin.com/in/j%C3%B6rg-m%C3%BCller" { t.Errorf("got %q", got) } if got := renderRenkuURL("jane.doe_1"); got != "https://renkulab.io/u/jane.doe_1" { From ad2ebc223afa8c0cba50403f7ae1db2db72cb138 Mon Sep 17 00:00:00 2001 From: Martin Fontanet Date: Tue, 13 Oct 2026 20:41:56 +0200 Subject: [PATCH 18/23] fix: codegen --- .../proto/user/messages/user_svc/add_role_request.pb.go | 2 +- .../proto/user/messages/user_svc/remove_role_request.pb.go | 2 +- components/backend/internal/proto/user/user_service.pb.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go index 27f7dba9..b8add87a 100644 --- a/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/add_role_request.pb.go @@ -79,7 +79,7 @@ var File_user_messages_user_svc_add_role_request_proto protoreflect.FileDescript const file_user_messages_user_svc_add_role_request_proto_rawDesc = "" + "\n" + - "-user/messages/user_svc/add_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\x1a\x1bbuf/validate/validate.proto\"b\n" + + "-user/messages/user_svc/add_role_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\x1a\x1fuser/entities/global_role.proto\"b\n" + "\x0eAddRoleRequest\x12!\n" + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12-\n" + "\x04role\x18\x02 \x01(\x0e2\x19.user.entities.GlobalRoleR\x04roleBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" diff --git a/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go index 059e2098..2191a633 100644 --- a/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go +++ b/components/backend/internal/proto/user/messages/user_svc/remove_role_request.pb.go @@ -79,7 +79,7 @@ var File_user_messages_user_svc_remove_role_request_proto protoreflect.FileDescr const file_user_messages_user_svc_remove_role_request_proto_rawDesc = "" + "\n" + - "0user/messages/user_svc/remove_role_request.proto\x12\x16user.messages.user_svc\x1a\x1fuser/entities/global_role.proto\x1a\x1bbuf/validate/validate.proto\"e\n" + + "0user/messages/user_svc/remove_role_request.proto\x12\x16user.messages.user_svc\x1a\x1bbuf/validate/validate.proto\x1a\x1fuser/entities/global_role.proto\"e\n" + "\x11RemoveRoleRequest\x12!\n" + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12-\n" + "\x04role\x18\x02 \x01(\x0e2\x19.user.entities.GlobalRoleR\x04roleBeZcgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svcb\x06proto3" diff --git a/components/backend/internal/proto/user/user_service.pb.go b/components/backend/internal/proto/user/user_service.pb.go index 6d22714e..38b359d8 100644 --- a/components/backend/internal/proto/user/user_service.pb.go +++ b/components/backend/internal/proto/user/user_service.pb.go @@ -25,7 +25,7 @@ var File_user_user_service_proto protoreflect.FileDescriptor const file_user_user_service_proto_rawDesc = "" + "\n" + - "\x17user/user_service.proto\x12\x04user\x1a-user/messages/user_svc/add_role_request.proto\x1a.user/messages/user_svc/add_role_response.proto\x1a(user/messages/user_svc/get_request.proto\x1a)user/messages/user_svc/get_response.proto\x1a)user/messages/user_svc/edit_request.proto\x1a*user/messages/user_svc/edit_response.proto\x1a)user/messages/user_svc/list_request.proto\x1a*user/messages/user_svc/list_response.proto\x1a-user/messages/user_svc/register_request.proto\x1a.user/messages/user_svc/register_response.proto\x1a0user/messages/user_svc/remove_role_request.proto\x1a1user/messages/user_svc/remove_role_response.proto\x1a-user/messages/user_svc/who_am_i_request.proto\x1a.user/messages/user_svc/who_am_i_response.proto2\xfc\x04\n" + + "\x17user/user_service.proto\x12\x04user\x1a-user/messages/user_svc/add_role_request.proto\x1a.user/messages/user_svc/add_role_response.proto\x1a)user/messages/user_svc/edit_request.proto\x1a*user/messages/user_svc/edit_response.proto\x1a(user/messages/user_svc/get_request.proto\x1a)user/messages/user_svc/get_response.proto\x1a)user/messages/user_svc/list_request.proto\x1a*user/messages/user_svc/list_response.proto\x1a-user/messages/user_svc/register_request.proto\x1a.user/messages/user_svc/register_response.proto\x1a0user/messages/user_svc/remove_role_request.proto\x1a1user/messages/user_svc/remove_role_response.proto\x1a-user/messages/user_svc/who_am_i_request.proto\x1a.user/messages/user_svc/who_am_i_response.proto2\xfc\x04\n" + "\vUserService\x12Q\n" + "\x04List\x12#.user.messages.user_svc.ListRequest\x1a$.user.messages.user_svc.ListResponse\x12N\n" + "\x03Get\x12\".user.messages.user_svc.GetRequest\x1a#.user.messages.user_svc.GetResponse\x12Q\n" + From df7a563a770d42c5e2dc209da57bd5c5f1e11e3d Mon Sep 17 00:00:00 2001 From: marftn Date: Wed, 14 Oct 2026 16:23:18 +0200 Subject: [PATCH 19/23] fix: return permission denied when editing another user as non-admin --- components/backend/internal/service/user_service.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index e2dff071..42aab51f 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -118,6 +118,14 @@ func (s *UserService) Edit( u, err := updateQuery.Save(ctx) if err != nil { if ent.IsNotFound(err) { + if !isAdmin { + // We return permission denied whether the user exists or not to avoid leaking which user IDs exist. + return nil, status.Error( + codes.PermissionDenied, + "you can only edit your own profile", + ) + } + return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } slog.Error("query user", "err", err) From dbe7a0391ded988c65fa5b97ffb4bbbd94e9adeb Mon Sep 17 00:00:00 2001 From: Martin Fontanet <11040743+marftn@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:30:37 +0200 Subject: [PATCH 20/23] chore: improve comments and logs; interpret whitespaces links as empty Co-authored-by: Sabine Maennel --- api/proto/user/entities/user.proto | 2 +- components/backend/db/schema/user.go | 6 +++--- components/backend/internal/service/user_service.go | 4 ++-- components/backend/internal/service/userlinks.go | 10 +++++++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index a8afaedd..f5825014 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -18,7 +18,7 @@ message User { repeated GlobalRole roles = 7; google.protobuf.Timestamp modified_at = 8; - // User accounts links. + // /Profile links, rendered by the backend from stored usernames. Empty string when unset. string renku_url = 9; string github_url = 10; string linkedin_url = 11; diff --git a/components/backend/db/schema/user.go b/components/backend/db/schema/user.go index 1be94629..3770376f 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -33,11 +33,11 @@ func (User) Fields() []ent.Field { field.String("email").Optional().Default(""). Comment("Email of the user, same as in Keycloak"), field.String("renku").Optional().Default(""). - Comment("Renku account of the user."), + Comment("Renku username (not a URL), the service renders the link."), field.String("github").Optional().Default(""). - Comment("Github account of the user."), + Comment("Github username (not a URL), the service renders the link."), field.String("linkedin").Optional().Default(""). - Comment("LinkedIn account of the user."), + Comment("LinkedIn username (not a URL), the service renders the link."), field.Time("created_at").Immutable().Default(time.Now). Comment("Timestamp when the user was first seen."), field.Time("modified_at"). diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index 42aab51f..3b0356be 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -128,9 +128,9 @@ func (s *UserService) Edit( return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetUserId()) } - slog.Error("query user", "err", err) + slog.Error("update user", "err", err) - return nil, status.Error(codes.Internal, "couldn't query database") + return nil, status.Error(codes.Internal, "couldn't update database") } entry, err := s.userWithRoles(u) diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index fdac5243..00805e4d 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -63,9 +63,13 @@ func linkUsername( link *string, label string, ) (*string, error) { - if link == nil || *link == "" { - return link, nil - } + if link == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*link) + if trimmed == "" { + return &trimmed, nil + } username, err := parse(*link) if err != nil { From dd39bf1f07ea4a1dc9d274353934855951c2eac4 Mon Sep 17 00:00:00 2001 From: marftn Date: Wed, 14 Oct 2026 16:43:13 +0200 Subject: [PATCH 21/23] fix: lint and tests --- .../internal/service/user_service_test.go | 19 +++++++++++++++++-- .../backend/internal/service/userlinks.go | 15 ++++++++------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 81dc3619..2d7a4377 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -953,7 +953,7 @@ var _ = Describe("UserService", func() { LinkedinUrl: &newLinkedin, }) - Expect(status.Code(err)).To(Equal(codes.NotFound)) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) u, err := dbClient.User.Get(context.Background(), targetUser.ID) Expect(err).NotTo(HaveOccurred()) @@ -1024,7 +1024,7 @@ var _ = Describe("UserService", func() { Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) }) - It("returns NotFound for a non-existent user", func() { + It("returns NotFound to admins for a non-existent user", func() { adminToken := testutils.CreateTestJWTToken(testAdmin) ctx := metadata.NewOutgoingContext( context.Background(), @@ -1039,6 +1039,21 @@ var _ = Describe("UserService", func() { Expect(status.Code(err)).To(Equal(codes.NotFound)) }) + It("returns PermissionDenied to non-admins for a non-existent user", func() { + // Same code as for an existing user they don't own, so non-admins can't probe which IDs exist. + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := userClient.Edit(ctx, &msgs.EditRequest{ + UserId: uuid.NewString(), + GithubUrl: &newGithub, + }) + + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + type input struct { renku *string github *string diff --git a/components/backend/internal/service/userlinks.go b/components/backend/internal/service/userlinks.go index 00805e4d..b80f5a88 100644 --- a/components/backend/internal/service/userlinks.go +++ b/components/backend/internal/service/userlinks.go @@ -63,13 +63,14 @@ func linkUsername( link *string, label string, ) (*string, error) { - if link == nil { - return nil, nil - } - trimmed := strings.TrimSpace(*link) - if trimmed == "" { - return &trimmed, nil - } + if link == nil { + //nolint:nilnil // This is not an error. Returning nil,nil is the expected behavior. + return nil, nil + } + trimmed := strings.TrimSpace(*link) + if trimmed == "" { + return &trimmed, nil + } username, err := parse(*link) if err != nil { From 00845a50522db62eeb78b0f0b6bff5bcbe81611d Mon Sep 17 00:00:00 2001 From: marftn Date: Wed, 14 Oct 2026 16:43:52 +0200 Subject: [PATCH 22/23] chore: add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceba8131..e98338f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- A user profile can hold links to the person's GitHub, LinkedIn and Renku + accounts. People can set their own links and administrators can set anyone's; + only links to those three sites are accepted. No page shows or edits them yet. - Organizers have a Manage Submissions page: every team and where it stands with its submission — nothing yet, drafts only, or final — with the teams still owing an entry first. Tabs filter by standing, a search finds a team by its From 2949f9c285ce6e73f83557f190d9bca3c86a62d4 Mon Sep 17 00:00:00 2001 From: marftn Date: Wed, 14 Oct 2026 16:47:57 +0200 Subject: [PATCH 23/23] fix: codegen --- api/proto/API.md | 2 +- components/backend/Schema.md | 6 +++--- components/backend/ent/user.go | 6 +++--- components/backend/internal/proto/user/entities/user.pb.go | 2 +- .../src/lib/server/grpc/generated/user/entities/user.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/proto/API.md b/api/proto/API.md index 6bb3d394..f7ce78ea 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -795,7 +795,7 @@ See components/backend/internal/middleware/rbac.go. | email | [string](#string) | | | | roles | [GlobalRole](#user-entities-GlobalRole) | repeated | Populated from casbin on fetch; not persisted in ent DB. | | modified_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | | -| renku_url | [string](#string) | | User accounts links. | +| renku_url | [string](#string) | | Profile links, rendered by the backend from stored usernames. Empty string when unset. | | github_url | [string](#string) | | | | linkedin_url | [string](#string) | | | diff --git a/components/backend/Schema.md b/components/backend/Schema.md index d3942900..61f90fde 100644 --- a/components/backend/Schema.md +++ b/components/backend/Schema.md @@ -394,9 +394,9 @@ An authenticated user, synced from Keycloak on first login. | `keycloak_id` | string | yes | yes | no | no | Unique identifier from Keycloak (sub claim). | | `display_name` | string | no | no | no | yes | Preferred display name of the user. | | `email` | string | no | no | no | yes | Email of the user, same as in Keycloak | -| `renku` | string | no | no | no | yes | Renku account of the user. | -| `github` | string | no | no | no | yes | Github account of the user. | -| `linkedin` | string | no | no | no | yes | LinkedIn account of the user. | +| `renku` | string | no | no | no | yes | Renku username (not a URL), the service renders the link. | +| `github` | string | no | no | no | yes | Github username (not a URL), the service renders the link. | +| `linkedin` | string | no | no | no | yes | LinkedIn username (not a URL), the service renders the link. | | `created_at` | time.Time | yes | no | yes | yes | Timestamp when the user was first seen. | | `modified_at` | time.Time | yes | no | no | yes | Timestamp of the last profile update. | diff --git a/components/backend/ent/user.go b/components/backend/ent/user.go index 7adc6758..1afd00ec 100644 --- a/components/backend/ent/user.go +++ b/components/backend/ent/user.go @@ -26,11 +26,11 @@ type User struct { DisplayName string `json:"display_name,omitempty"` // Email of the user, same as in Keycloak Email string `json:"email,omitempty"` - // Renku account of the user. + // Renku username (not a URL), the service renders the link. Renku string `json:"renku,omitempty"` - // Github account of the user. + // Github username (not a URL), the service renders the link. Github string `json:"github,omitempty"` - // LinkedIn account of the user. + // LinkedIn username (not a URL), the service renders the link. Linkedin string `json:"linkedin,omitempty"` // Timestamp when the user was first seen. CreatedAt time.Time `json:"created_at,omitempty"` diff --git a/components/backend/internal/proto/user/entities/user.pb.go b/components/backend/internal/proto/user/entities/user.pb.go index 7a69d882..ec5a5aec 100644 --- a/components/backend/internal/proto/user/entities/user.pb.go +++ b/components/backend/internal/proto/user/entities/user.pb.go @@ -33,7 +33,7 @@ type User struct { // Populated from casbin on fetch; not persisted in ent DB. Roles []GlobalRole `protobuf:"varint,7,rep,packed,name=roles,proto3,enum=user.entities.GlobalRole" json:"roles,omitempty"` ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` - // User accounts links. + // /Profile links, rendered by the backend from stored usernames. Empty string when unset. RenkuUrl string `protobuf:"bytes,9,opt,name=renku_url,json=renkuUrl,proto3" json:"renku_url,omitempty"` GithubUrl string `protobuf:"bytes,10,opt,name=github_url,json=githubUrl,proto3" json:"github_url,omitempty"` LinkedinUrl string `protobuf:"bytes,11,opt,name=linkedin_url,json=linkedinUrl,proto3" json:"linkedin_url,omitempty"` diff --git a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts index 33c58471..e6627a9b 100644 --- a/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts +++ b/components/frontend/src/lib/server/grpc/generated/user/entities/user.ts @@ -23,7 +23,7 @@ export interface User { modifiedAt: | Date | undefined; - /** User accounts links. */ + /** /Profile links, rendered by the backend from stored usernames. Empty string when unset. */ renkuUrl: string; githubUrl: string; linkedinUrl: string;