From 531fad0ad879fbd1729686baf0b96fb515d6fb3c Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:12:03 +0200 Subject: [PATCH] feat(user): let users edit GitHub, Renku and LinkedIn profile links Adds three platform-owned profile links to User and an EditProfile RPC that edits only the caller's own row: the request carries no user id, the target is always the token's subject. Casbin gets a per-user /user/ domain and a wildcard write rule on it; anonymous callers are refused before the check, since "*" matches them too. Links are validated server-side: https only; GitHub and LinkedIn must be profile pages on their own hosts; Renku accepts any https host, as it is also self-hosted. The Keycloak claim sync never touches them. --- .claude/settings.json | 11 + api/proto/API.md | 76 ++++++ api/proto/user/entities/user.proto | 4 + .../user_svc/edit_profile_request.proto | 14 + .../user_svc/edit_profile_response.proto | 11 + api/proto/user/user_service.proto | 3 + components/backend/Schema.md | 3 + components/backend/db/schema/user.go | 8 + 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/middleware/rbac.go | 29 ++- .../internal/proto/user/entities/user.pb.go | 38 ++- .../user_svc/edit_profile_request.pb.go | 149 +++++++++++ .../user_svc/edit_profile_response.pb.go | 125 +++++++++ .../internal/proto/user/user_service.pb.go | 49 ++-- .../proto/user/user_service_grpc.pb.go | 50 +++- .../backend/internal/service/mappers.go | 3 + .../backend/internal/service/user_service.go | 141 ++++++++++ .../internal/service/user_service_test.go | 137 ++++++++++ .../grpc/generated/user/entities/user.ts | 71 ++++- .../messages/user_svc/edit_profile_request.ts | 146 +++++++++++ .../user_svc/edit_profile_response.ts | 94 +++++++ .../grpc/generated/user/user_service.ts | 18 ++ 29 files changed, 2088 insertions(+), 39 deletions(-) create mode 100644 .claude/settings.json create mode 100644 api/proto/user/messages/user_svc/edit_profile_request.proto create mode 100644 api/proto/user/messages/user_svc/edit_profile_response.proto create mode 100644 components/backend/internal/proto/user/messages/user_svc/edit_profile_request.pb.go create mode 100644 components/backend/internal/proto/user/messages/user_svc/edit_profile_response.pb.go create mode 100644 components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_request.ts create mode 100644 components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_response.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..2cf63f46 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(just nix::develop default bash -c 'just codegen::proto >/dev/null && just codegen::db-schema >/dev/null && echo CODEGEN-OK && just check::test -c backend 2>&1 | tail -40 && just check::lint -c backend 2>&1 | tail -30')", + "Bash(just nix::develop default bash -c 'just check::lint -c backend 2>&1')", + "Bash(just nix::develop default bash -c 'just check::lint -c backend 2>&1 | grep -E \"\\\\.go:[0-9]+|successful|not successful\" ; just check::test -c backend 2>&1 | grep -E \"Ran [0-9]+|Passed|FAIL\"')", + "Bash(nix run *)", + "Bash(git add *)" + ] + } +} diff --git a/api/proto/API.md b/api/proto/API.md index 88d10f1c..63d5e429 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_profile_request.proto](#user_messages_user_svc_edit_profile_request-proto) + - [EditProfileRequest](#user-messages-user_svc-EditProfileRequest) + +- [user/messages/user_svc/edit_profile_response.proto](#user_messages_user_svc_edit_profile_response-proto) + - [EditProfileResponse](#user-messages-user_svc-EditProfileResponse) + - [user/messages/user_svc/get_request.proto](#user_messages_user_svc_get_request-proto) - [GetRequest](#user-messages-user_svc-GetRequest) @@ -789,6 +795,9 @@ 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) | | | +| github_url | [string](#string) | | Profile links the user maintains via EditProfile; empty when unset. | +| renku_url | [string](#string) | | | +| linkedin_url | [string](#string) | | | @@ -5746,6 +5755,72 @@ casbin role for this hackathon; `is_waiting` is false once approved. + +

Top

+ +## user/messages/user_svc/edit_profile_request.proto + + + + + +### EditProfileRequest +Edits the caller's own profile. There is no user id: the target is always +the user the token belongs to. An absent field is left as is; an empty +string clears the link. + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| github_url | [string](#string) | optional | | +| renku_url | [string](#string) | optional | | +| linkedin_url | [string](#string) | optional | | + + + + + + + + + + + + + + + + +

Top

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

Top

@@ -6068,6 +6143,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | 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) | | | RemoveRole | [messages.user_svc.RemoveRoleRequest](#user-messages-user_svc-RemoveRoleRequest) | [messages.user_svc.RemoveRoleResponse](#user-messages-user_svc-RemoveRoleResponse) | | +| EditProfile | [messages.user_svc.EditProfileRequest](#user-messages-user_svc-EditProfileRequest) | [messages.user_svc.EditProfileResponse](#user-messages-user_svc-EditProfileResponse) | | diff --git a/api/proto/user/entities/user.proto b/api/proto/user/entities/user.proto index bfd08072..824b765c 100644 --- a/api/proto/user/entities/user.proto +++ b/api/proto/user/entities/user.proto @@ -17,4 +17,8 @@ message User { // Populated from casbin on fetch; not persisted in ent DB. repeated GlobalRole roles = 7; google.protobuf.Timestamp modified_at = 8; + // Profile links the user maintains via EditProfile; empty when unset. + string github_url = 9; + string renku_url = 10; + string linkedin_url = 11; } diff --git a/api/proto/user/messages/user_svc/edit_profile_request.proto b/api/proto/user/messages/user_svc/edit_profile_request.proto new file mode 100644 index 00000000..0c1c9bae --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_profile_request.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package user.messages.user_svc; + +option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/messages/user_svc"; + +// Edits the caller's own profile. There is no user id: the target is always +// the user the token belongs to. An absent field is left as is; an empty +// string clears the link. +message EditProfileRequest { + optional string github_url = 1; + optional string renku_url = 2; + optional string linkedin_url = 3; +} diff --git a/api/proto/user/messages/user_svc/edit_profile_response.proto b/api/proto/user/messages/user_svc/edit_profile_response.proto new file mode 100644 index 00000000..39275b99 --- /dev/null +++ b/api/proto/user/messages/user_svc/edit_profile_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 EditProfileResponse { + user.entities.User user = 1; +} diff --git a/api/proto/user/user_service.proto b/api/proto/user/user_service.proto index de4e204c..207cec20 100644 --- a/api/proto/user/user_service.proto +++ b/api/proto/user/user_service.proto @@ -4,6 +4,8 @@ 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/edit_profile_request.proto"; +import "user/messages/user_svc/edit_profile_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"; @@ -24,4 +26,5 @@ service UserService { 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); rpc RemoveRole(user.messages.user_svc.RemoveRoleRequest) returns (user.messages.user_svc.RemoveRoleResponse); + rpc EditProfile(user.messages.user_svc.EditProfileRequest) returns (user.messages.user_svc.EditProfileResponse); } diff --git a/components/backend/Schema.md b/components/backend/Schema.md index adc2f85d..c24317e8 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 | +| `github_url` | string | no | no | no | yes | Link to the user's GitHub profile, set by the user. | +| `renku_url` | string | no | no | no | yes | Link to the user's Renku profile, set by the user. | +| `linkedin_url` | string | no | no | no | yes | Link to the user's LinkedIn profile, set by 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..476ed268 100644 --- a/components/backend/db/schema/user.go +++ b/components/backend/db/schema/user.go @@ -32,6 +32,14 @@ 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"), + // The profile links below are owned by the platform, not Keycloak: the + // user edits them here, and the claim sync on login never touches them. + field.String("github_url").Optional().Default(""). + Comment("Link to the user's GitHub profile, set by the user."), + field.String("renku_url").Optional().Default(""). + Comment("Link to the user's Renku profile, set by the user."), + field.String("linkedin_url").Optional().Default(""). + Comment("Link to the user's LinkedIn profile, set by 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..a70b3dd8 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: "github_url", Type: field.TypeString, Nullable: true, Default: ""}, + {Name: "renku_url", Type: field.TypeString, Nullable: true, Default: ""}, + {Name: "linkedin_url", 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..26c9c576 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 + github_url *string + renku_url *string + linkedin_url *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) } +// SetGithubURL sets the "github_url" field. +func (m *UserMutation) SetGithubURL(s string) { + m.github_url = &s +} + +// GithubURL returns the value of the "github_url" field in the mutation. +func (m *UserMutation) GithubURL() (r string, exists bool) { + v := m.github_url + if v == nil { + return + } + return *v, true +} + +// OldGithubURL returns the old "github_url" 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) OldGithubURL(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldGithubURL is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldGithubURL requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldGithubURL: %w", err) + } + return oldValue.GithubURL, nil +} + +// ClearGithubURL clears the value of the "github_url" field. +func (m *UserMutation) ClearGithubURL() { + m.github_url = nil + m.clearedFields[user.FieldGithubURL] = struct{}{} +} + +// GithubURLCleared returns if the "github_url" field was cleared in this mutation. +func (m *UserMutation) GithubURLCleared() bool { + _, ok := m.clearedFields[user.FieldGithubURL] + return ok +} + +// ResetGithubURL resets all changes to the "github_url" field. +func (m *UserMutation) ResetGithubURL() { + m.github_url = nil + delete(m.clearedFields, user.FieldGithubURL) +} + +// SetRenkuURL sets the "renku_url" field. +func (m *UserMutation) SetRenkuURL(s string) { + m.renku_url = &s +} + +// RenkuURL returns the value of the "renku_url" field in the mutation. +func (m *UserMutation) RenkuURL() (r string, exists bool) { + v := m.renku_url + if v == nil { + return + } + return *v, true +} + +// OldRenkuURL returns the old "renku_url" 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) OldRenkuURL(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldRenkuURL is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldRenkuURL requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldRenkuURL: %w", err) + } + return oldValue.RenkuURL, nil +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (m *UserMutation) ClearRenkuURL() { + m.renku_url = nil + m.clearedFields[user.FieldRenkuURL] = struct{}{} +} + +// RenkuURLCleared returns if the "renku_url" field was cleared in this mutation. +func (m *UserMutation) RenkuURLCleared() bool { + _, ok := m.clearedFields[user.FieldRenkuURL] + return ok +} + +// ResetRenkuURL resets all changes to the "renku_url" field. +func (m *UserMutation) ResetRenkuURL() { + m.renku_url = nil + delete(m.clearedFields, user.FieldRenkuURL) +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (m *UserMutation) SetLinkedinURL(s string) { + m.linkedin_url = &s +} + +// LinkedinURL returns the value of the "linkedin_url" field in the mutation. +func (m *UserMutation) LinkedinURL() (r string, exists bool) { + v := m.linkedin_url + if v == nil { + return + } + return *v, true +} + +// OldLinkedinURL returns the old "linkedin_url" 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) OldLinkedinURL(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLinkedinURL is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLinkedinURL requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLinkedinURL: %w", err) + } + return oldValue.LinkedinURL, nil +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (m *UserMutation) ClearLinkedinURL() { + m.linkedin_url = nil + m.clearedFields[user.FieldLinkedinURL] = struct{}{} +} + +// LinkedinURLCleared returns if the "linkedin_url" field was cleared in this mutation. +func (m *UserMutation) LinkedinURLCleared() bool { + _, ok := m.clearedFields[user.FieldLinkedinURL] + return ok +} + +// ResetLinkedinURL resets all changes to the "linkedin_url" field. +func (m *UserMutation) ResetLinkedinURL() { + m.linkedin_url = nil + delete(m.clearedFields, user.FieldLinkedinURL) +} + // 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.github_url != nil { + fields = append(fields, user.FieldGithubURL) + } + if m.renku_url != nil { + fields = append(fields, user.FieldRenkuURL) + } + if m.linkedin_url != nil { + fields = append(fields, user.FieldLinkedinURL) + } 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.FieldGithubURL: + return m.GithubURL() + case user.FieldRenkuURL: + return m.RenkuURL() + case user.FieldLinkedinURL: + return m.LinkedinURL() 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.FieldGithubURL: + return m.OldGithubURL(ctx) + case user.FieldRenkuURL: + return m.OldRenkuURL(ctx) + case user.FieldLinkedinURL: + return m.OldLinkedinURL(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.FieldGithubURL: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetGithubURL(v) + return nil + case user.FieldRenkuURL: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetRenkuURL(v) + return nil + case user.FieldLinkedinURL: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLinkedinURL(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.FieldGithubURL) { + fields = append(fields, user.FieldGithubURL) + } + if m.FieldCleared(user.FieldRenkuURL) { + fields = append(fields, user.FieldRenkuURL) + } + if m.FieldCleared(user.FieldLinkedinURL) { + fields = append(fields, user.FieldLinkedinURL) + } return fields } @@ -14520,6 +14721,15 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldEmail: m.ClearEmail() return nil + case user.FieldGithubURL: + m.ClearGithubURL() + return nil + case user.FieldRenkuURL: + m.ClearRenkuURL() + return nil + case user.FieldLinkedinURL: + m.ClearLinkedinURL() + 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.FieldGithubURL: + m.ResetGithubURL() + return nil + case user.FieldRenkuURL: + m.ResetRenkuURL() + return nil + case user.FieldLinkedinURL: + m.ResetLinkedinURL() + 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..2ae576f7 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) + // userDescGithubURL is the schema descriptor for github_url field. + userDescGithubURL := userFields[4].Descriptor() + // user.DefaultGithubURL holds the default value on creation for the github_url field. + user.DefaultGithubURL = userDescGithubURL.Default.(string) + // userDescRenkuURL is the schema descriptor for renku_url field. + userDescRenkuURL := userFields[5].Descriptor() + // user.DefaultRenkuURL holds the default value on creation for the renku_url field. + user.DefaultRenkuURL = userDescRenkuURL.Default.(string) + // userDescLinkedinURL is the schema descriptor for linkedin_url field. + userDescLinkedinURL := userFields[6].Descriptor() + // user.DefaultLinkedinURL holds the default value on creation for the linkedin_url field. + user.DefaultLinkedinURL = userDescLinkedinURL.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..9fdbeb7e 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"` + // Link to the user's GitHub profile, set by the user. + GithubURL string `json:"github_url,omitempty"` + // Link to the user's Renku profile, set by the user. + RenkuURL string `json:"renku_url,omitempty"` + // Link to the user's LinkedIn profile, set by the user. + LinkedinURL string `json:"linkedin_url,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.FieldGithubURL, user.FieldRenkuURL, user.FieldLinkedinURL: 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.FieldGithubURL: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field github_url", values[i]) + } else if value.Valid { + _m.GithubURL = value.String + } + case user.FieldRenkuURL: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field renku_url", values[i]) + } else if value.Valid { + _m.RenkuURL = value.String + } + case user.FieldLinkedinURL: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field linkedin_url", values[i]) + } else if value.Valid { + _m.LinkedinURL = 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("github_url=") + builder.WriteString(_m.GithubURL) + builder.WriteString(", ") + builder.WriteString("renku_url=") + builder.WriteString(_m.RenkuURL) + builder.WriteString(", ") + builder.WriteString("linkedin_url=") + builder.WriteString(_m.LinkedinURL) + 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..64498f73 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" + // FieldGithubURL holds the string denoting the github_url field in the database. + FieldGithubURL = "github_url" + // FieldRenkuURL holds the string denoting the renku_url field in the database. + FieldRenkuURL = "renku_url" + // FieldLinkedinURL holds the string denoting the linkedin_url field in the database. + FieldLinkedinURL = "linkedin_url" // 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, + FieldGithubURL, + FieldRenkuURL, + FieldLinkedinURL, FieldCreatedAt, FieldModifiedAt, } @@ -319,6 +328,12 @@ var ( DefaultDisplayName string // DefaultEmail holds the default value on creation for the "email" field. DefaultEmail string + // DefaultGithubURL holds the default value on creation for the "github_url" field. + DefaultGithubURL string + // DefaultRenkuURL holds the default value on creation for the "renku_url" field. + DefaultRenkuURL string + // DefaultLinkedinURL holds the default value on creation for the "linkedin_url" field. + DefaultLinkedinURL 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() } +// ByGithubURL orders the results by the github_url field. +func ByGithubURL(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldGithubURL, opts...).ToFunc() +} + +// ByRenkuURL orders the results by the renku_url field. +func ByRenkuURL(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldRenkuURL, opts...).ToFunc() +} + +// ByLinkedinURL orders the results by the linkedin_url field. +func ByLinkedinURL(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLinkedinURL, 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..90aafbb0 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)) } +// GithubURL applies equality check predicate on the "github_url" field. It's identical to GithubURLEQ. +func GithubURL(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldGithubURL, v)) +} + +// RenkuURL applies equality check predicate on the "renku_url" field. It's identical to RenkuURLEQ. +func RenkuURL(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldRenkuURL, v)) +} + +// LinkedinURL applies equality check predicate on the "linkedin_url" field. It's identical to LinkedinURLEQ. +func LinkedinURL(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldLinkedinURL, 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)) } +// GithubURLEQ applies the EQ predicate on the "github_url" field. +func GithubURLEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldGithubURL, v)) +} + +// GithubURLNEQ applies the NEQ predicate on the "github_url" field. +func GithubURLNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldGithubURL, v)) +} + +// GithubURLIn applies the In predicate on the "github_url" field. +func GithubURLIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldGithubURL, vs...)) +} + +// GithubURLNotIn applies the NotIn predicate on the "github_url" field. +func GithubURLNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldGithubURL, vs...)) +} + +// GithubURLGT applies the GT predicate on the "github_url" field. +func GithubURLGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldGithubURL, v)) +} + +// GithubURLGTE applies the GTE predicate on the "github_url" field. +func GithubURLGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldGithubURL, v)) +} + +// GithubURLLT applies the LT predicate on the "github_url" field. +func GithubURLLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldGithubURL, v)) +} + +// GithubURLLTE applies the LTE predicate on the "github_url" field. +func GithubURLLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldGithubURL, v)) +} + +// GithubURLContains applies the Contains predicate on the "github_url" field. +func GithubURLContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldGithubURL, v)) +} + +// GithubURLHasPrefix applies the HasPrefix predicate on the "github_url" field. +func GithubURLHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldGithubURL, v)) +} + +// GithubURLHasSuffix applies the HasSuffix predicate on the "github_url" field. +func GithubURLHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldGithubURL, v)) +} + +// GithubURLIsNil applies the IsNil predicate on the "github_url" field. +func GithubURLIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldGithubURL)) +} + +// GithubURLNotNil applies the NotNil predicate on the "github_url" field. +func GithubURLNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldGithubURL)) +} + +// GithubURLEqualFold applies the EqualFold predicate on the "github_url" field. +func GithubURLEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldGithubURL, v)) +} + +// GithubURLContainsFold applies the ContainsFold predicate on the "github_url" field. +func GithubURLContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldGithubURL, v)) +} + +// RenkuURLEQ applies the EQ predicate on the "renku_url" field. +func RenkuURLEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldRenkuURL, v)) +} + +// RenkuURLNEQ applies the NEQ predicate on the "renku_url" field. +func RenkuURLNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldRenkuURL, v)) +} + +// RenkuURLIn applies the In predicate on the "renku_url" field. +func RenkuURLIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldRenkuURL, vs...)) +} + +// RenkuURLNotIn applies the NotIn predicate on the "renku_url" field. +func RenkuURLNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldRenkuURL, vs...)) +} + +// RenkuURLGT applies the GT predicate on the "renku_url" field. +func RenkuURLGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldRenkuURL, v)) +} + +// RenkuURLGTE applies the GTE predicate on the "renku_url" field. +func RenkuURLGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldRenkuURL, v)) +} + +// RenkuURLLT applies the LT predicate on the "renku_url" field. +func RenkuURLLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldRenkuURL, v)) +} + +// RenkuURLLTE applies the LTE predicate on the "renku_url" field. +func RenkuURLLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldRenkuURL, v)) +} + +// RenkuURLContains applies the Contains predicate on the "renku_url" field. +func RenkuURLContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldRenkuURL, v)) +} + +// RenkuURLHasPrefix applies the HasPrefix predicate on the "renku_url" field. +func RenkuURLHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldRenkuURL, v)) +} + +// RenkuURLHasSuffix applies the HasSuffix predicate on the "renku_url" field. +func RenkuURLHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldRenkuURL, v)) +} + +// RenkuURLIsNil applies the IsNil predicate on the "renku_url" field. +func RenkuURLIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldRenkuURL)) +} + +// RenkuURLNotNil applies the NotNil predicate on the "renku_url" field. +func RenkuURLNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldRenkuURL)) +} + +// RenkuURLEqualFold applies the EqualFold predicate on the "renku_url" field. +func RenkuURLEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldRenkuURL, v)) +} + +// RenkuURLContainsFold applies the ContainsFold predicate on the "renku_url" field. +func RenkuURLContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldRenkuURL, v)) +} + +// LinkedinURLEQ applies the EQ predicate on the "linkedin_url" field. +func LinkedinURLEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldLinkedinURL, v)) +} + +// LinkedinURLNEQ applies the NEQ predicate on the "linkedin_url" field. +func LinkedinURLNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldLinkedinURL, v)) +} + +// LinkedinURLIn applies the In predicate on the "linkedin_url" field. +func LinkedinURLIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldLinkedinURL, vs...)) +} + +// LinkedinURLNotIn applies the NotIn predicate on the "linkedin_url" field. +func LinkedinURLNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldLinkedinURL, vs...)) +} + +// LinkedinURLGT applies the GT predicate on the "linkedin_url" field. +func LinkedinURLGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldLinkedinURL, v)) +} + +// LinkedinURLGTE applies the GTE predicate on the "linkedin_url" field. +func LinkedinURLGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldLinkedinURL, v)) +} + +// LinkedinURLLT applies the LT predicate on the "linkedin_url" field. +func LinkedinURLLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldLinkedinURL, v)) +} + +// LinkedinURLLTE applies the LTE predicate on the "linkedin_url" field. +func LinkedinURLLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldLinkedinURL, v)) +} + +// LinkedinURLContains applies the Contains predicate on the "linkedin_url" field. +func LinkedinURLContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldLinkedinURL, v)) +} + +// LinkedinURLHasPrefix applies the HasPrefix predicate on the "linkedin_url" field. +func LinkedinURLHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldLinkedinURL, v)) +} + +// LinkedinURLHasSuffix applies the HasSuffix predicate on the "linkedin_url" field. +func LinkedinURLHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldLinkedinURL, v)) +} + +// LinkedinURLIsNil applies the IsNil predicate on the "linkedin_url" field. +func LinkedinURLIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldLinkedinURL)) +} + +// LinkedinURLNotNil applies the NotNil predicate on the "linkedin_url" field. +func LinkedinURLNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldLinkedinURL)) +} + +// LinkedinURLEqualFold applies the EqualFold predicate on the "linkedin_url" field. +func LinkedinURLEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldLinkedinURL, v)) +} + +// LinkedinURLContainsFold applies the ContainsFold predicate on the "linkedin_url" field. +func LinkedinURLContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldLinkedinURL, 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..3f3cf55c 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 } +// SetGithubURL sets the "github_url" field. +func (_c *UserCreate) SetGithubURL(v string) *UserCreate { + _c.mutation.SetGithubURL(v) + return _c +} + +// SetNillableGithubURL sets the "github_url" field if the given value is not nil. +func (_c *UserCreate) SetNillableGithubURL(v *string) *UserCreate { + if v != nil { + _c.SetGithubURL(*v) + } + return _c +} + +// SetRenkuURL sets the "renku_url" field. +func (_c *UserCreate) SetRenkuURL(v string) *UserCreate { + _c.mutation.SetRenkuURL(v) + return _c +} + +// SetNillableRenkuURL sets the "renku_url" field if the given value is not nil. +func (_c *UserCreate) SetNillableRenkuURL(v *string) *UserCreate { + if v != nil { + _c.SetRenkuURL(*v) + } + return _c +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (_c *UserCreate) SetLinkedinURL(v string) *UserCreate { + _c.mutation.SetLinkedinURL(v) + return _c +} + +// SetNillableLinkedinURL sets the "linkedin_url" field if the given value is not nil. +func (_c *UserCreate) SetNillableLinkedinURL(v *string) *UserCreate { + if v != nil { + _c.SetLinkedinURL(*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.GithubURL(); !ok { + v := user.DefaultGithubURL + _c.mutation.SetGithubURL(v) + } + if _, ok := _c.mutation.RenkuURL(); !ok { + v := user.DefaultRenkuURL + _c.mutation.SetRenkuURL(v) + } + if _, ok := _c.mutation.LinkedinURL(); !ok { + v := user.DefaultLinkedinURL + _c.mutation.SetLinkedinURL(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.GithubURL(); ok { + _spec.SetField(user.FieldGithubURL, field.TypeString, value) + _node.GithubURL = value + } + if value, ok := _c.mutation.RenkuURL(); ok { + _spec.SetField(user.FieldRenkuURL, field.TypeString, value) + _node.RenkuURL = value + } + if value, ok := _c.mutation.LinkedinURL(); ok { + _spec.SetField(user.FieldLinkedinURL, field.TypeString, value) + _node.LinkedinURL = 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 } +// SetGithubURL sets the "github_url" field. +func (u *UserUpsert) SetGithubURL(v string) *UserUpsert { + u.Set(user.FieldGithubURL, v) + return u +} + +// UpdateGithubURL sets the "github_url" field to the value that was provided on create. +func (u *UserUpsert) UpdateGithubURL() *UserUpsert { + u.SetExcluded(user.FieldGithubURL) + return u +} + +// ClearGithubURL clears the value of the "github_url" field. +func (u *UserUpsert) ClearGithubURL() *UserUpsert { + u.SetNull(user.FieldGithubURL) + return u +} + +// SetRenkuURL sets the "renku_url" field. +func (u *UserUpsert) SetRenkuURL(v string) *UserUpsert { + u.Set(user.FieldRenkuURL, v) + return u +} + +// UpdateRenkuURL sets the "renku_url" field to the value that was provided on create. +func (u *UserUpsert) UpdateRenkuURL() *UserUpsert { + u.SetExcluded(user.FieldRenkuURL) + return u +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (u *UserUpsert) ClearRenkuURL() *UserUpsert { + u.SetNull(user.FieldRenkuURL) + return u +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (u *UserUpsert) SetLinkedinURL(v string) *UserUpsert { + u.Set(user.FieldLinkedinURL, v) + return u +} + +// UpdateLinkedinURL sets the "linkedin_url" field to the value that was provided on create. +func (u *UserUpsert) UpdateLinkedinURL() *UserUpsert { + u.SetExcluded(user.FieldLinkedinURL) + return u +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (u *UserUpsert) ClearLinkedinURL() *UserUpsert { + u.SetNull(user.FieldLinkedinURL) + 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 { }) } +// SetGithubURL sets the "github_url" field. +func (u *UserUpsertOne) SetGithubURL(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetGithubURL(v) + }) +} + +// UpdateGithubURL sets the "github_url" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateGithubURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateGithubURL() + }) +} + +// ClearGithubURL clears the value of the "github_url" field. +func (u *UserUpsertOne) ClearGithubURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearGithubURL() + }) +} + +// SetRenkuURL sets the "renku_url" field. +func (u *UserUpsertOne) SetRenkuURL(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetRenkuURL(v) + }) +} + +// UpdateRenkuURL sets the "renku_url" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateRenkuURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateRenkuURL() + }) +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (u *UserUpsertOne) ClearRenkuURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearRenkuURL() + }) +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (u *UserUpsertOne) SetLinkedinURL(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetLinkedinURL(v) + }) +} + +// UpdateLinkedinURL sets the "linkedin_url" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateLinkedinURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateLinkedinURL() + }) +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (u *UserUpsertOne) ClearLinkedinURL() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearLinkedinURL() + }) +} + // 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 { }) } +// SetGithubURL sets the "github_url" field. +func (u *UserUpsertBulk) SetGithubURL(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetGithubURL(v) + }) +} + +// UpdateGithubURL sets the "github_url" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateGithubURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateGithubURL() + }) +} + +// ClearGithubURL clears the value of the "github_url" field. +func (u *UserUpsertBulk) ClearGithubURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearGithubURL() + }) +} + +// SetRenkuURL sets the "renku_url" field. +func (u *UserUpsertBulk) SetRenkuURL(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetRenkuURL(v) + }) +} + +// UpdateRenkuURL sets the "renku_url" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateRenkuURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateRenkuURL() + }) +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (u *UserUpsertBulk) ClearRenkuURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearRenkuURL() + }) +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (u *UserUpsertBulk) SetLinkedinURL(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetLinkedinURL(v) + }) +} + +// UpdateLinkedinURL sets the "linkedin_url" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateLinkedinURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateLinkedinURL() + }) +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (u *UserUpsertBulk) ClearLinkedinURL() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearLinkedinURL() + }) +} + // 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..4cc691e2 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 } +// SetGithubURL sets the "github_url" field. +func (_u *UserUpdate) SetGithubURL(v string) *UserUpdate { + _u.mutation.SetGithubURL(v) + return _u +} + +// SetNillableGithubURL sets the "github_url" field if the given value is not nil. +func (_u *UserUpdate) SetNillableGithubURL(v *string) *UserUpdate { + if v != nil { + _u.SetGithubURL(*v) + } + return _u +} + +// ClearGithubURL clears the value of the "github_url" field. +func (_u *UserUpdate) ClearGithubURL() *UserUpdate { + _u.mutation.ClearGithubURL() + return _u +} + +// SetRenkuURL sets the "renku_url" field. +func (_u *UserUpdate) SetRenkuURL(v string) *UserUpdate { + _u.mutation.SetRenkuURL(v) + return _u +} + +// SetNillableRenkuURL sets the "renku_url" field if the given value is not nil. +func (_u *UserUpdate) SetNillableRenkuURL(v *string) *UserUpdate { + if v != nil { + _u.SetRenkuURL(*v) + } + return _u +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (_u *UserUpdate) ClearRenkuURL() *UserUpdate { + _u.mutation.ClearRenkuURL() + return _u +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (_u *UserUpdate) SetLinkedinURL(v string) *UserUpdate { + _u.mutation.SetLinkedinURL(v) + return _u +} + +// SetNillableLinkedinURL sets the "linkedin_url" field if the given value is not nil. +func (_u *UserUpdate) SetNillableLinkedinURL(v *string) *UserUpdate { + if v != nil { + _u.SetLinkedinURL(*v) + } + return _u +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (_u *UserUpdate) ClearLinkedinURL() *UserUpdate { + _u.mutation.ClearLinkedinURL() + 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.GithubURL(); ok { + _spec.SetField(user.FieldGithubURL, field.TypeString, value) + } + if _u.mutation.GithubURLCleared() { + _spec.ClearField(user.FieldGithubURL, field.TypeString) + } + if value, ok := _u.mutation.RenkuURL(); ok { + _spec.SetField(user.FieldRenkuURL, field.TypeString, value) + } + if _u.mutation.RenkuURLCleared() { + _spec.ClearField(user.FieldRenkuURL, field.TypeString) + } + if value, ok := _u.mutation.LinkedinURL(); ok { + _spec.SetField(user.FieldLinkedinURL, field.TypeString, value) + } + if _u.mutation.LinkedinURLCleared() { + _spec.ClearField(user.FieldLinkedinURL, 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 } +// SetGithubURL sets the "github_url" field. +func (_u *UserUpdateOne) SetGithubURL(v string) *UserUpdateOne { + _u.mutation.SetGithubURL(v) + return _u +} + +// SetNillableGithubURL sets the "github_url" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableGithubURL(v *string) *UserUpdateOne { + if v != nil { + _u.SetGithubURL(*v) + } + return _u +} + +// ClearGithubURL clears the value of the "github_url" field. +func (_u *UserUpdateOne) ClearGithubURL() *UserUpdateOne { + _u.mutation.ClearGithubURL() + return _u +} + +// SetRenkuURL sets the "renku_url" field. +func (_u *UserUpdateOne) SetRenkuURL(v string) *UserUpdateOne { + _u.mutation.SetRenkuURL(v) + return _u +} + +// SetNillableRenkuURL sets the "renku_url" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableRenkuURL(v *string) *UserUpdateOne { + if v != nil { + _u.SetRenkuURL(*v) + } + return _u +} + +// ClearRenkuURL clears the value of the "renku_url" field. +func (_u *UserUpdateOne) ClearRenkuURL() *UserUpdateOne { + _u.mutation.ClearRenkuURL() + return _u +} + +// SetLinkedinURL sets the "linkedin_url" field. +func (_u *UserUpdateOne) SetLinkedinURL(v string) *UserUpdateOne { + _u.mutation.SetLinkedinURL(v) + return _u +} + +// SetNillableLinkedinURL sets the "linkedin_url" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableLinkedinURL(v *string) *UserUpdateOne { + if v != nil { + _u.SetLinkedinURL(*v) + } + return _u +} + +// ClearLinkedinURL clears the value of the "linkedin_url" field. +func (_u *UserUpdateOne) ClearLinkedinURL() *UserUpdateOne { + _u.mutation.ClearLinkedinURL() + 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.GithubURL(); ok { + _spec.SetField(user.FieldGithubURL, field.TypeString, value) + } + if _u.mutation.GithubURLCleared() { + _spec.ClearField(user.FieldGithubURL, field.TypeString) + } + if value, ok := _u.mutation.RenkuURL(); ok { + _spec.SetField(user.FieldRenkuURL, field.TypeString, value) + } + if _u.mutation.RenkuURLCleared() { + _spec.ClearField(user.FieldRenkuURL, field.TypeString) + } + if value, ok := _u.mutation.LinkedinURL(); ok { + _spec.SetField(user.FieldLinkedinURL, field.TypeString, value) + } + if _u.mutation.LinkedinURLCleared() { + _spec.ClearField(user.FieldLinkedinURL, field.TypeString) + } if value, ok := _u.mutation.ModifiedAt(); ok { _spec.SetField(user.FieldModifiedAt, field.TypeTime, value) } diff --git a/components/backend/internal/middleware/rbac.go b/components/backend/internal/middleware/rbac.go index 5e82851a..7c9de84d 100644 --- a/components/backend/internal/middleware/rbac.go +++ b/components/backend/internal/middleware/rbac.go @@ -65,8 +65,9 @@ const ( Vote VoteCategory VoteResult - // Note: this is a dummy entry, there are no rules for users, since we only use admin checks with users. - // This is just here so we have something we can query on when checking admin permissions for the user table. + // Reading and listing users is admin-only, which needs no rule of its own. + // The one rule naming users lets anyone write their own profile: see + // WithUser. User ) @@ -178,6 +179,7 @@ func NewRBACEnforcer(cfg *config.Config) (*Enforcer, error) { const ( anyHackathonPath = "/hackathon/*" anyTeamPath = "/hackathon/*/team/*" + anyUserPath = "/user/*" ) func defaultPolicies(cfg *config.Config, e *casbin.Enforcer) error { @@ -245,6 +247,9 @@ func defaultPolicies(cfg *config.Config, e *casbin.Enforcer) error { {Owner.String(), anyHackathonPath, VoteResult.String(), Read.String()}, {Owner.String(), anyHackathonPath, VoteResult.String(), Write.String()}, {Owner.String(), anyHackathonPath, Vote.String(), Read.String()}, + // Anyone can edit a user profile. Which profile is decided by the + // handler, which only ever passes the caller's own WithUser domain. + {"*", anyUserPath, User.String(), Write.String()}, } // AddPoliciesEx adds what is missing and skips the rest. @@ -272,6 +277,12 @@ func teamDomainPath(domain, teamId string) string { return fmt.Sprintf("%s/team/%s", domain, teamId) } +// userDomainPath returns the domain path for one user's own resources, +// e.g. /user/. It is not nested under a hackathon. +func userDomainPath(keycloakID string) string { + return fmt.Sprintf("/user/%s", keycloakID) +} + // projectDomainPath returns the full domain path for a project resource, // e.g. /hackathon//project/. func projectDomainPath(domain, projectId string) string { @@ -285,6 +296,10 @@ func enforceOptsToPath(hackathonId string, opts ...EnforceOption) string { opt(options) } + if options.userID != "" { + return userDomainPath(options.userID) + } + domain := hackathonIdToPath(hackathonId) if options.projectID != "" { domain = projectDomainPath(domain, options.projectID) @@ -497,6 +512,7 @@ func (e *Enforcer) GetHackathonRole( type enforceOptions struct { teamID string projectID string + userID string } type EnforceOption func(*enforceOptions) @@ -519,6 +535,15 @@ func WithProject(projectID string) EnforceOption { } } +// WithUser replaces the /hackathon/ domain path with +// /user/, for operations on a user's own data rather than on +// anything inside a hackathon. The hackathon id is ignored. +func WithUser(keycloakID string) EnforceOption { + return func(o *enforceOptions) { + o.userID = keycloakID + } +} + func (e *Enforcer) Enforce( ctx context.Context, hackathonId string, diff --git a/components/backend/internal/proto/user/entities/user.pb.go b/components/backend/internal/proto/user/entities/user.pb.go index 7a09b687..d4ab04a4 100644 --- a/components/backend/internal/proto/user/entities/user.pb.go +++ b/components/backend/internal/proto/user/entities/user.pb.go @@ -31,8 +31,12 @@ type User struct { 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"` // 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,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"` + // Profile links the user maintains via EditProfile; empty when unset. + GithubUrl string `protobuf:"bytes,9,opt,name=github_url,json=githubUrl,proto3" json:"github_url,omitempty"` + RenkuUrl string `protobuf:"bytes,10,opt,name=renku_url,json=renkuUrl,proto3" json:"renku_url,omitempty"` + LinkedinUrl string `protobuf:"bytes,11,opt,name=linkedin_url,json=linkedinUrl,proto3" json:"linkedin_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -123,11 +127,32 @@ func (x *User) GetModifiedAt() *timestamppb.Timestamp { return nil } +func (x *User) GetGithubUrl() string { + if x != nil { + return x.GithubUrl + } + return "" +} + +func (x *User) GetRenkuUrl() string { + if x != nil { + return x.RenkuUrl + } + return "" +} + +func (x *User) GetLinkedinUrl() string { + if x != nil { + return x.LinkedinUrl + } + return "" +} + 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\"\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" + @@ -139,7 +164,12 @@ const file_user_entities_user_proto_rawDesc = "" + "\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" + - "modifiedAtB\\ZZgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/user/entitiesb\x06proto3" + "modifiedAt\x12\x1d\n" + + "\n" + + "github_url\x18\t \x01(\tR\tgithubUrl\x12\x1b\n" + + "\trenku_url\x18\n" + + " \x01(\tR\brenkuUrl\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/proto/user/messages/user_svc/edit_profile_request.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_profile_request.pb.go new file mode 100644 index 00000000..af2d6fd5 --- /dev/null +++ b/components/backend/internal/proto/user/messages/user_svc/edit_profile_request.pb.go @@ -0,0 +1,149 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: user/messages/user_svc/edit_profile_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) +) + +// Edits the caller's own profile. There is no user id: the target is always +// the user the token belongs to. An absent field is left as is; an empty +// string clears the link. +type EditProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GithubUrl *string `protobuf:"bytes,1,opt,name=github_url,json=githubUrl,proto3,oneof" json:"github_url,omitempty"` + RenkuUrl *string `protobuf:"bytes,2,opt,name=renku_url,json=renkuUrl,proto3,oneof" json:"renku_url,omitempty"` + LinkedinUrl *string `protobuf:"bytes,3,opt,name=linkedin_url,json=linkedinUrl,proto3,oneof" json:"linkedin_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditProfileRequest) Reset() { + *x = EditProfileRequest{} + mi := &file_user_messages_user_svc_edit_profile_request_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditProfileRequest) ProtoMessage() {} + +func (x *EditProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_user_messages_user_svc_edit_profile_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 EditProfileRequest.ProtoReflect.Descriptor instead. +func (*EditProfileRequest) Descriptor() ([]byte, []int) { + return file_user_messages_user_svc_edit_profile_request_proto_rawDescGZIP(), []int{0} +} + +func (x *EditProfileRequest) GetGithubUrl() string { + if x != nil && x.GithubUrl != nil { + return *x.GithubUrl + } + return "" +} + +func (x *EditProfileRequest) GetRenkuUrl() string { + if x != nil && x.RenkuUrl != nil { + return *x.RenkuUrl + } + return "" +} + +func (x *EditProfileRequest) GetLinkedinUrl() string { + if x != nil && x.LinkedinUrl != nil { + return *x.LinkedinUrl + } + return "" +} + +var File_user_messages_user_svc_edit_profile_request_proto protoreflect.FileDescriptor + +const file_user_messages_user_svc_edit_profile_request_proto_rawDesc = "" + + "\n" + + "1user/messages/user_svc/edit_profile_request.proto\x12\x16user.messages.user_svc\"\xb0\x01\n" + + "\x12EditProfileRequest\x12\"\n" + + "\n" + + "github_url\x18\x01 \x01(\tH\x00R\tgithubUrl\x88\x01\x01\x12 \n" + + "\trenku_url\x18\x02 \x01(\tH\x01R\brenkuUrl\x88\x01\x01\x12&\n" + + "\flinkedin_url\x18\x03 \x01(\tH\x02R\vlinkedinUrl\x88\x01\x01B\r\n" + + "\v_github_urlB\f\n" + + "\n" + + "_renku_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_profile_request_proto_rawDescOnce sync.Once + file_user_messages_user_svc_edit_profile_request_proto_rawDescData []byte +) + +func file_user_messages_user_svc_edit_profile_request_proto_rawDescGZIP() []byte { + file_user_messages_user_svc_edit_profile_request_proto_rawDescOnce.Do(func() { + file_user_messages_user_svc_edit_profile_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_profile_request_proto_rawDesc), len(file_user_messages_user_svc_edit_profile_request_proto_rawDesc))) + }) + return file_user_messages_user_svc_edit_profile_request_proto_rawDescData +} + +var file_user_messages_user_svc_edit_profile_request_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_user_messages_user_svc_edit_profile_request_proto_goTypes = []any{ + (*EditProfileRequest)(nil), // 0: user.messages.user_svc.EditProfileRequest +} +var file_user_messages_user_svc_edit_profile_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_profile_request_proto_init() } +func file_user_messages_user_svc_edit_profile_request_proto_init() { + if File_user_messages_user_svc_edit_profile_request_proto != nil { + return + } + file_user_messages_user_svc_edit_profile_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_profile_request_proto_rawDesc), len(file_user_messages_user_svc_edit_profile_request_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_user_messages_user_svc_edit_profile_request_proto_goTypes, + DependencyIndexes: file_user_messages_user_svc_edit_profile_request_proto_depIdxs, + MessageInfos: file_user_messages_user_svc_edit_profile_request_proto_msgTypes, + }.Build() + File_user_messages_user_svc_edit_profile_request_proto = out.File + file_user_messages_user_svc_edit_profile_request_proto_goTypes = nil + file_user_messages_user_svc_edit_profile_request_proto_depIdxs = nil +} diff --git a/components/backend/internal/proto/user/messages/user_svc/edit_profile_response.pb.go b/components/backend/internal/proto/user/messages/user_svc/edit_profile_response.pb.go new file mode 100644 index 00000000..75c1abf2 --- /dev/null +++ b/components/backend/internal/proto/user/messages/user_svc/edit_profile_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_profile_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 EditProfileResponse 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 *EditProfileResponse) Reset() { + *x = EditProfileResponse{} + mi := &file_user_messages_user_svc_edit_profile_response_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditProfileResponse) ProtoMessage() {} + +func (x *EditProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_user_messages_user_svc_edit_profile_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 EditProfileResponse.ProtoReflect.Descriptor instead. +func (*EditProfileResponse) Descriptor() ([]byte, []int) { + return file_user_messages_user_svc_edit_profile_response_proto_rawDescGZIP(), []int{0} +} + +func (x *EditProfileResponse) GetUser() *entities.User { + if x != nil { + return x.User + } + return nil +} + +var File_user_messages_user_svc_edit_profile_response_proto protoreflect.FileDescriptor + +const file_user_messages_user_svc_edit_profile_response_proto_rawDesc = "" + + "\n" + + "2user/messages/user_svc/edit_profile_response.proto\x12\x16user.messages.user_svc\x1a\x18user/entities/user.proto\">\n" + + "\x13EditProfileResponse\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_profile_response_proto_rawDescOnce sync.Once + file_user_messages_user_svc_edit_profile_response_proto_rawDescData []byte +) + +func file_user_messages_user_svc_edit_profile_response_proto_rawDescGZIP() []byte { + file_user_messages_user_svc_edit_profile_response_proto_rawDescOnce.Do(func() { + file_user_messages_user_svc_edit_profile_response_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_user_messages_user_svc_edit_profile_response_proto_rawDesc), len(file_user_messages_user_svc_edit_profile_response_proto_rawDesc))) + }) + return file_user_messages_user_svc_edit_profile_response_proto_rawDescData +} + +var file_user_messages_user_svc_edit_profile_response_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_user_messages_user_svc_edit_profile_response_proto_goTypes = []any{ + (*EditProfileResponse)(nil), // 0: user.messages.user_svc.EditProfileResponse + (*entities.User)(nil), // 1: user.entities.User +} +var file_user_messages_user_svc_edit_profile_response_proto_depIdxs = []int32{ + 1, // 0: user.messages.user_svc.EditProfileResponse.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_profile_response_proto_init() } +func file_user_messages_user_svc_edit_profile_response_proto_init() { + if File_user_messages_user_svc_edit_profile_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_profile_response_proto_rawDesc), len(file_user_messages_user_svc_edit_profile_response_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_user_messages_user_svc_edit_profile_response_proto_goTypes, + DependencyIndexes: file_user_messages_user_svc_edit_profile_response_proto_depIdxs, + MessageInfos: file_user_messages_user_svc_edit_profile_response_proto_msgTypes, + }.Build() + File_user_messages_user_svc_edit_profile_response_proto = out.File + file_user_messages_user_svc_edit_profile_response_proto_goTypes = nil + file_user_messages_user_svc_edit_profile_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..dd0e15a4 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/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\x1a1user/messages/user_svc/edit_profile_request.proto\x1a2user/messages/user_svc/edit_profile_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\x91\x05\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" + @@ -33,21 +33,24 @@ const file_user_user_service_proto_rawDesc = "" + "\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" + "\n" + - "RemoveRole\x12).user.messages.user_svc.RemoveRoleRequest\x1a*.user.messages.user_svc.RemoveRoleResponseBSZQgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/userb\x06proto3" + "RemoveRole\x12).user.messages.user_svc.RemoveRoleRequest\x1a*.user.messages.user_svc.RemoveRoleResponse\x12f\n" + + "\vEditProfile\x12*.user.messages.user_svc.EditProfileRequest\x1a+.user.messages.user_svc.EditProfileResponseBSZQgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/userb\x06proto3" 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.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.EditProfileRequest)(nil), // 6: user.messages.user_svc.EditProfileRequest + (*user_svc.ListResponse)(nil), // 7: user.messages.user_svc.ListResponse + (*user_svc.GetResponse)(nil), // 8: user.messages.user_svc.GetResponse + (*user_svc.WhoAmIResponse)(nil), // 9: user.messages.user_svc.WhoAmIResponse + (*user_svc.RegisterResponse)(nil), // 10: user.messages.user_svc.RegisterResponse + (*user_svc.AddRoleResponse)(nil), // 11: user.messages.user_svc.AddRoleResponse + (*user_svc.RemoveRoleResponse)(nil), // 12: user.messages.user_svc.RemoveRoleResponse + (*user_svc.EditProfileResponse)(nil), // 13: user.messages.user_svc.EditProfileResponse } var file_user_user_service_proto_depIdxs = []int32{ 0, // 0: user.UserService.List:input_type -> user.messages.user_svc.ListRequest @@ -56,14 +59,16 @@ var file_user_user_service_proto_depIdxs = []int32{ 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 + 6, // 6: user.UserService.EditProfile:input_type -> user.messages.user_svc.EditProfileRequest + 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.WhoAmI:output_type -> user.messages.user_svc.WhoAmIResponse + 10, // 10: user.UserService.Register:output_type -> user.messages.user_svc.RegisterResponse + 11, // 11: user.UserService.AddRole:output_type -> user.messages.user_svc.AddRoleResponse + 12, // 12: user.UserService.RemoveRole:output_type -> user.messages.user_svc.RemoveRoleResponse + 13, // 13: user.UserService.EditProfile:output_type -> user.messages.user_svc.EditProfileResponse + 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..e699889d 100644 --- a/components/backend/internal/proto/user/user_service_grpc.pb.go +++ b/components/backend/internal/proto/user/user_service_grpc.pb.go @@ -20,12 +20,13 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - UserService_List_FullMethodName = "/user.UserService/List" - UserService_Get_FullMethodName = "/user.UserService/Get" - UserService_WhoAmI_FullMethodName = "/user.UserService/WhoAmI" - UserService_Register_FullMethodName = "/user.UserService/Register" - UserService_AddRole_FullMethodName = "/user.UserService/AddRole" - UserService_RemoveRole_FullMethodName = "/user.UserService/RemoveRole" + UserService_List_FullMethodName = "/user.UserService/List" + UserService_Get_FullMethodName = "/user.UserService/Get" + UserService_WhoAmI_FullMethodName = "/user.UserService/WhoAmI" + UserService_Register_FullMethodName = "/user.UserService/Register" + UserService_AddRole_FullMethodName = "/user.UserService/AddRole" + UserService_RemoveRole_FullMethodName = "/user.UserService/RemoveRole" + UserService_EditProfile_FullMethodName = "/user.UserService/EditProfile" ) // UserServiceClient is the client API for UserService service. @@ -38,6 +39,7 @@ type UserServiceClient interface { 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) RemoveRole(ctx context.Context, in *user_svc.RemoveRoleRequest, opts ...grpc.CallOption) (*user_svc.RemoveRoleResponse, error) + EditProfile(ctx context.Context, in *user_svc.EditProfileRequest, opts ...grpc.CallOption) (*user_svc.EditProfileResponse, error) } type userServiceClient struct { @@ -108,6 +110,16 @@ func (c *userServiceClient) RemoveRole(ctx context.Context, in *user_svc.RemoveR return out, nil } +func (c *userServiceClient) EditProfile(ctx context.Context, in *user_svc.EditProfileRequest, opts ...grpc.CallOption) (*user_svc.EditProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(user_svc.EditProfileResponse) + err := c.cc.Invoke(ctx, UserService_EditProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // UserServiceServer is the server API for UserService service. // All implementations must embed UnimplementedUserServiceServer // for forward compatibility. @@ -118,6 +130,7 @@ type UserServiceServer interface { Register(context.Context, *user_svc.RegisterRequest) (*user_svc.RegisterResponse, error) AddRole(context.Context, *user_svc.AddRoleRequest) (*user_svc.AddRoleResponse, error) RemoveRole(context.Context, *user_svc.RemoveRoleRequest) (*user_svc.RemoveRoleResponse, error) + EditProfile(context.Context, *user_svc.EditProfileRequest) (*user_svc.EditProfileResponse, error) mustEmbedUnimplementedUserServiceServer() } @@ -146,6 +159,9 @@ func (UnimplementedUserServiceServer) AddRole(context.Context, *user_svc.AddRole func (UnimplementedUserServiceServer) RemoveRole(context.Context, *user_svc.RemoveRoleRequest) (*user_svc.RemoveRoleResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveRole not implemented") } +func (UnimplementedUserServiceServer) EditProfile(context.Context, *user_svc.EditProfileRequest) (*user_svc.EditProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EditProfile not implemented") +} func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {} func (UnimplementedUserServiceServer) testEmbeddedByValue() {} @@ -275,6 +291,24 @@ func _UserService_RemoveRole_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _UserService_EditProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(user_svc.EditProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).EditProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_EditProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).EditProfile(ctx, req.(*user_svc.EditProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + // UserService_ServiceDesc is the grpc.ServiceDesc for UserService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -306,6 +340,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RemoveRole", Handler: _UserService_RemoveRole_Handler, }, + { + MethodName: "EditProfile", + Handler: _UserService_EditProfile_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "user/user_service.proto", diff --git a/components/backend/internal/service/mappers.go b/components/backend/internal/service/mappers.go index c6a31e56..ff5eeffc 100644 --- a/components/backend/internal/service/mappers.go +++ b/components/backend/internal/service/mappers.go @@ -24,6 +24,9 @@ func userEntryFromEnt(u *ent.User) *userEnts.User { Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), ModifiedAt: timestamppb.New(u.ModifiedAt), + GithubUrl: u.GithubURL, + RenkuUrl: u.RenkuURL, + LinkedinUrl: u.LinkedinURL, } } diff --git a/components/backend/internal/service/user_service.go b/components/backend/internal/service/user_service.go index f5b45f5a..266eb6b6 100644 --- a/components/backend/internal/service/user_service.go +++ b/components/backend/internal/service/user_service.go @@ -3,6 +3,9 @@ package service import ( "context" "log/slog" + "net/url" + "regexp" + "strings" "github.com/google/uuid" "github.com/swissdatasciencecenter/hackagon/components/backend/ent" @@ -308,3 +311,141 @@ func (s *UserService) Register( return &msgs.RegisterResponse{User: userEntryFromEnt(u)}, nil } + +// EditProfile updates the caller's own profile links. The request names no +// user: the target is always the token's subject, so nobody can edit anyone +// else's profile. +func (s *UserService) EditProfile( + ctx context.Context, + req *msgs.EditProfileRequest, +) (*msgs.EditProfileResponse, error) { + sub, _, err := m.RequireSubject(ctx) + if err != nil { + return nil, err + } + // The write rule is a wildcard, which the anonymous subject also matches. + if sub == m.AnonSubject { + return nil, status.Error(codes.Unauthenticated, "sign in to edit your profile") + } + if err := s.enforcer.RequirePermission(ctx, "", m.User, m.Write, m.WithUser(sub)); err != nil { + return nil, err + } + + u, err := s.dbClient.User.Query(). + Where(entuser.KeycloakIDEQ(sub)). + Only(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, status.Error(codes.NotFound, "user not registered on platform") + } + slog.Error("query user", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + + update := u.Update() + if req.GithubUrl != nil { + v, err := checkProfileURL("github_url", req.GetGithubUrl(), githubProfile()) + if err != nil { + return nil, err + } + update.SetGithubURL(v) + } + if req.RenkuUrl != nil { + v, err := checkProfileURL("renku_url", req.GetRenkuUrl(), renkuProfile()) + if err != nil { + return nil, err + } + update.SetRenkuURL(v) + } + if req.LinkedinUrl != nil { + v, err := checkProfileURL("linkedin_url", req.GetLinkedinUrl(), linkedinProfile()) + if err != nil { + return nil, err + } + update.SetLinkedinURL(v) + } + u, err = update.Save(ctx) + if err != nil { + slog.Error("update user profile", "err", err) + + return nil, status.Error(codes.Internal, "couldn't update profile") + } + + 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.EditProfileResponse{User: entry}, nil +} + +// maxProfileURLLength bounds a profile link; real ones are far shorter. +const maxProfileURLLength = 512 + +// profileSite says what a link to one site's profile page looks like. A nil +// host or path accepts any. +type profileSite struct { + host func(host string) bool + path *regexp.Regexp + example string +} + +func githubProfile() profileSite { + return profileSite{ + host: func(h string) bool { return h == "github.com" || h == "www.github.com" }, + // GitHub usernames: alphanumerics and single hyphens, at most 39 long. + path: regexp.MustCompile(`^/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/?$`), + example: "https://github.com/", + } +} + +func linkedinProfile() profileSite { + return profileSite{ + // Country subdomains such as ch.linkedin.com serve the same profiles. + host: func(h string) bool { return h == "linkedin.com" || strings.HasSuffix(h, ".linkedin.com") }, + path: regexp.MustCompile(`^/in/[^/]+/?$`), + example: "https://www.linkedin.com/in/", + } +} + +// renkuProfile accepts any https address: Renku runs on more than one host +// (renkulab.io and self-hosted deployments). +func renkuProfile() profileSite { + return profileSite{ + host: nil, + path: nil, + example: "https://renkulab.io/...", + } +} + +// checkProfileURL validates one profile link and returns it trimmed. Empty +// clears the link. Links are shown to other participants as clickable, so +// only https addresses to the expected site are accepted. +func checkProfileURL(field, raw string, site profileSite) (string, error) { + v := strings.TrimSpace(raw) + if v == "" { + return "", nil + } + if len(v) > maxProfileURLLength { + return "", status.Errorf(codes.InvalidArgument, + "%s: longer than %d characters", field, maxProfileURLLength) + } + u, err := url.Parse(v) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return "", status.Errorf(codes.InvalidArgument, + "%s: must be an https:// address, like %s", field, site.example) + } + host := strings.ToLower(u.Hostname()) + if (site.host != nil && !site.host(host)) || + (site.path != nil && !site.path.MatchString(u.Path)) { + return "", status.Errorf(codes.InvalidArgument, + "%s: must look like %s", field, site.example) + } + + return v, nil +} diff --git a/components/backend/internal/service/user_service_test.go b/components/backend/internal/service/user_service_test.go index 616757e0..ad164532 100644 --- a/components/backend/internal/service/user_service_test.go +++ b/components/backend/internal/service/user_service_test.go @@ -853,4 +853,141 @@ var _ = Describe("UserService", func() { Expect(st.Code()).To(Equal(codes.PermissionDenied)) }) }) + + // ---- EditProfile ---- + + Describe("EditProfile", func() { + + var ( + me *ent.User + ctx context.Context + ) + + BeforeEach(func() { + var err error + me, err = dbClient.User.Create(). + SetKeycloakID("edit-profile-me"). + SetUsername("edit-profile-me"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + ctx = metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken(me.KeycloakID), + ), + ) + }) + + It("sets the caller's links, trimmed, and persists them", func() { + resp, err := userClient.EditProfile(ctx, &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr(" https://github.com/alice-dev "), + RenkuUrl: testutils.StringPtr("https://renkulab.io/u/alice"), + LinkedinUrl: testutils.StringPtr("https://ch.linkedin.com/in/alice/"), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetUser().GetGithubUrl()).To(Equal("https://github.com/alice-dev")) + Expect(resp.GetUser().GetRenkuUrl()).To(Equal("https://renkulab.io/u/alice")) + Expect(resp.GetUser().GetLinkedinUrl()).To(Equal("https://ch.linkedin.com/in/alice/")) + + stored := dbClient.User.GetX(context.Background(), me.ID) + Expect(stored.GithubURL).To(Equal("https://github.com/alice-dev")) + Expect(stored.RenkuURL).To(Equal("https://renkulab.io/u/alice")) + Expect(stored.LinkedinURL).To(Equal("https://ch.linkedin.com/in/alice/")) + }) + + It("leaves absent fields alone and clears empty ones", func() { + me = me.Update(). + SetGithubURL("https://github.com/keep"). + SetRenkuURL("https://renkulab.io/u/clear"). + SaveX(context.Background()) + + resp, err := userClient.EditProfile(ctx, &msgs.EditProfileRequest{ + RenkuUrl: testutils.StringPtr(""), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetUser().GetGithubUrl()).To(Equal("https://github.com/keep")) + Expect(resp.GetUser().GetRenkuUrl()).To(BeEmpty()) + }) + + It("edits only the caller's profile", func() { + other, err := dbClient.User.Create(). + SetKeycloakID("edit-profile-other"). + SetUsername("edit-profile-other"). + SetGithubURL("https://github.com/other"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + _, err = userClient.EditProfile(ctx, &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com/me"), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(dbClient.User.GetX(context.Background(), other.ID).GithubURL). + To(Equal("https://github.com/other")) + }) + + It("keeps the links when Register re-syncs from Keycloak", func() { + _, err := userClient.EditProfile(ctx, &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com/me"), + }) + Expect(err).NotTo(HaveOccurred()) + + // The test token carries no username claim, so Register rewrites + // the Keycloak-owned fields; the profile links must survive it. + resp, err := userClient.Register(ctx, &msgs.RegisterRequest{}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetUser().GetGithubUrl()).To(Equal("https://github.com/me")) + }) + + DescribeTable("rejects a link that is not a profile on the expected site", + func(req *msgs.EditProfileRequest) { + _, err := userClient.EditProfile(ctx, req) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }, + Entry("plain http", &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("http://github.com/me")}), + Entry("javascript scheme", &msgs.EditProfileRequest{ + RenkuUrl: testutils.StringPtr("javascript:alert(1)")}), + Entry("no host", &msgs.EditProfileRequest{ + RenkuUrl: testutils.StringPtr("https:///nohost")}), + Entry("credentials in the URL", &msgs.EditProfileRequest{ + RenkuUrl: testutils.StringPtr("https://user:pw@renkulab.io/u/me")}), + Entry("GitHub lookalike host", &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com.evil.example/me")}), + Entry("GitHub repository, not a profile", &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com/me/repo")}), + Entry("LinkedIn company page", &msgs.EditProfileRequest{ + LinkedinUrl: testutils.StringPtr("https://www.linkedin.com/company/sdsc")}), + Entry("LinkedIn lookalike host", &msgs.EditProfileRequest{ + LinkedinUrl: testutils.StringPtr("https://notlinkedin.com/in/me")}), + ) + + It("rejects the whole request when one link is invalid", func() { + _, err := userClient.EditProfile(ctx, &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com/me"), + LinkedinUrl: testutils.StringPtr("https://example.com"), + }) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + Expect(dbClient.User.GetX(context.Background(), me.ID).GithubURL).To(BeEmpty()) + }) + + It("returns NOT_FOUND when the caller is not registered", func() { + unregistered := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs( + "authorization", + "Bearer "+testutils.CreateTestJWTToken("edit-profile-nobody"), + ), + ) + _, err := userClient.EditProfile(unregistered, &msgs.EditProfileRequest{}) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + It("denies anonymous requests", func() { + _, err := userClient.EditProfile(context.Background(), &msgs.EditProfileRequest{ + GithubUrl: testutils.StringPtr("https://github.com/me"), + }) + Expect(status.Code(err)).To(Equal(codes.Unauthenticated)) + }) + }) }) 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..9781387f 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 @@ -20,7 +20,13 @@ export interface User { email: string; /** Populated from casbin on fetch; not persisted in ent DB. */ roles: GlobalRole[]; - modifiedAt: Date | undefined; + modifiedAt: + | Date + | undefined; + /** Profile links the user maintains via EditProfile; empty when unset. */ + githubUrl: string; + renkuUrl: string; + linkedinUrl: string; } function createBaseUser(): User { @@ -33,6 +39,9 @@ function createBaseUser(): User { email: "", roles: [], modifiedAt: undefined, + githubUrl: "", + renkuUrl: "", + linkedinUrl: "", }; } @@ -64,6 +73,15 @@ export const User: MessageFns = { if (message.modifiedAt !== undefined) { Timestamp.encode(toTimestamp(message.modifiedAt), writer.uint32(66).fork()).join(); } + if (message.githubUrl !== "") { + writer.uint32(74).string(message.githubUrl); + } + if (message.renkuUrl !== "") { + writer.uint32(82).string(message.renkuUrl); + } + if (message.linkedinUrl !== "") { + writer.uint32(90).string(message.linkedinUrl); + } return writer; }, @@ -148,6 +166,30 @@ export const User: MessageFns = { message.modifiedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); continue; } + case 9: { + if (tag !== 74) { + break; + } + + message.githubUrl = reader.string(); + continue; + } + case 10: { + if (tag !== 82) { + break; + } + + message.renkuUrl = reader.string(); + continue; + } + case 11: { + if (tag !== 90) { + break; + } + + message.linkedinUrl = reader.string(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -183,6 +225,21 @@ export const User: MessageFns = { : isSet(object.modified_at) ? fromJsonTimestamp(object.modified_at) : undefined, + githubUrl: isSet(object.githubUrl) + ? globalThis.String(object.githubUrl) + : isSet(object.github_url) + ? globalThis.String(object.github_url) + : "", + renkuUrl: isSet(object.renkuUrl) + ? globalThis.String(object.renkuUrl) + : isSet(object.renku_url) + ? globalThis.String(object.renku_url) + : "", + linkedinUrl: isSet(object.linkedinUrl) + ? globalThis.String(object.linkedinUrl) + : isSet(object.linkedin_url) + ? globalThis.String(object.linkedin_url) + : "", }; }, @@ -212,6 +269,15 @@ export const User: MessageFns = { if (message.modifiedAt !== undefined) { obj.modifiedAt = message.modifiedAt.toISOString(); } + if (message.githubUrl !== "") { + obj.githubUrl = message.githubUrl; + } + if (message.renkuUrl !== "") { + obj.renkuUrl = message.renkuUrl; + } + if (message.linkedinUrl !== "") { + obj.linkedinUrl = message.linkedinUrl; + } return obj; }, @@ -228,6 +294,9 @@ export const User: MessageFns = { message.email = object.email ?? ""; message.roles = object.roles?.map((e) => e) || []; message.modifiedAt = object.modifiedAt ?? undefined; + message.githubUrl = object.githubUrl ?? ""; + message.renkuUrl = object.renkuUrl ?? ""; + message.linkedinUrl = object.linkedinUrl ?? ""; return message; }, }; diff --git a/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_request.ts b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_request.ts new file mode 100644 index 00000000..5d0417c0 --- /dev/null +++ b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_request.ts @@ -0,0 +1,146 @@ +// 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_profile_request.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; + +export const protobufPackage = "user.messages.user_svc"; + +/** + * Edits the caller's own profile. There is no user id: the target is always + * the user the token belongs to. An absent field is left as is; an empty + * string clears the link. + */ +export interface EditProfileRequest { + githubUrl?: string | undefined; + renkuUrl?: string | undefined; + linkedinUrl?: string | undefined; +} + +function createBaseEditProfileRequest(): EditProfileRequest { + return { githubUrl: undefined, renkuUrl: undefined, linkedinUrl: undefined }; +} + +export const EditProfileRequest: MessageFns = { + encode(message: EditProfileRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.githubUrl !== undefined) { + writer.uint32(10).string(message.githubUrl); + } + if (message.renkuUrl !== undefined) { + writer.uint32(18).string(message.renkuUrl); + } + if (message.linkedinUrl !== undefined) { + writer.uint32(26).string(message.linkedinUrl); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): EditProfileRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEditProfileRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.githubUrl = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.renkuUrl = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.linkedinUrl = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): EditProfileRequest { + return { + githubUrl: isSet(object.githubUrl) + ? globalThis.String(object.githubUrl) + : isSet(object.github_url) + ? globalThis.String(object.github_url) + : undefined, + renkuUrl: isSet(object.renkuUrl) + ? globalThis.String(object.renkuUrl) + : isSet(object.renku_url) + ? globalThis.String(object.renku_url) + : undefined, + linkedinUrl: isSet(object.linkedinUrl) + ? globalThis.String(object.linkedinUrl) + : isSet(object.linkedin_url) + ? globalThis.String(object.linkedin_url) + : undefined, + }; + }, + + toJSON(message: EditProfileRequest): unknown { + const obj: any = {}; + if (message.githubUrl !== undefined) { + obj.githubUrl = message.githubUrl; + } + if (message.renkuUrl !== undefined) { + obj.renkuUrl = message.renkuUrl; + } + if (message.linkedinUrl !== undefined) { + obj.linkedinUrl = message.linkedinUrl; + } + return obj; + }, + + create(base?: DeepPartial): EditProfileRequest { + return EditProfileRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): EditProfileRequest { + const message = createBaseEditProfileRequest(); + message.githubUrl = object.githubUrl ?? undefined; + message.renkuUrl = object.renkuUrl ?? undefined; + message.linkedinUrl = object.linkedinUrl ?? 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_profile_response.ts b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_response.ts new file mode 100644 index 00000000..f8f05825 --- /dev/null +++ b/components/frontend/src/lib/server/grpc/generated/user/messages/user_svc/edit_profile_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_profile_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 EditProfileResponse { + user: User | undefined; +} + +function createBaseEditProfileResponse(): EditProfileResponse { + return { user: undefined }; +} + +export const EditProfileResponse: MessageFns = { + encode(message: EditProfileResponse, 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): EditProfileResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseEditProfileResponse(); + 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): EditProfileResponse { + return { user: isSet(object.user) ? User.fromJSON(object.user) : undefined }; + }, + + toJSON(message: EditProfileResponse): unknown { + const obj: any = {}; + if (message.user !== undefined) { + obj.user = User.toJSON(message.user); + } + return obj; + }, + + create(base?: DeepPartial): EditProfileResponse { + return EditProfileResponse.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): EditProfileResponse { + const message = createBaseEditProfileResponse(); + 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..bb487f42 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 { EditProfileRequest } from "./messages/user_svc/edit_profile_request"; +import { EditProfileResponse } from "./messages/user_svc/edit_profile_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"; @@ -74,6 +76,14 @@ export const UserServiceDefinition = { responseStream: false, options: {}, }, + editProfile: { + name: "EditProfile", + requestType: EditProfileRequest as typeof EditProfileRequest, + requestStream: false, + responseType: EditProfileResponse as typeof EditProfileResponse, + responseStream: false, + options: {}, + }, }, } as const; @@ -87,6 +97,10 @@ export interface UserServiceImplementation { request: RemoveRoleRequest, context: CallContext & CallContextExt, ): Promise>; + editProfile( + request: EditProfileRequest, + context: CallContext & CallContextExt, + ): Promise>; } export interface UserServiceClient { @@ -99,6 +113,10 @@ export interface UserServiceClient { request: DeepPartial, options?: CallOptions & CallOptionsExt, ): Promise; + editProfile( + request: DeepPartial, + options?: CallOptions & CallOptionsExt, + ): Promise; } type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;