Rework console pages and implement profile update

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-06 19:50:42 +04:00
parent 091210dee5
commit 3a459f1992
35 changed files with 2197 additions and 108 deletions

View File

@@ -65,14 +65,18 @@ func (p *MembershipProfile) AuthorizationAttributes(ctx context.Context, conn pg
q := `SELECT m.organization_id, mp.identity_id FROM iam_membership_profiles mp JOIN iam_memberships m ON mp.membership_id = m.id WHERE mp.id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID); err != nil {
var identityID gid.GID
if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID, &identityID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query membership profile authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
return map[string]string{
"organization_id": organizationID.String(),
"identity_id": identityID.String(),
}, nil
}
func (p *MembershipProfile) LoadByMembershipID(

View File

@@ -52,8 +52,9 @@ const (
ActionMembershipRoleSetOwner = "iam:membership-role:set-owner"
// Membership Profile actions
ActionMembershipProfileGet = "iam:membership-profile:get"
ActionMembershipProfileList = "iam:membership-profile:list"
ActionMembershipProfileGet = "iam:membership-profile:get"
ActionMembershipProfileList = "iam:membership-profile:list"
ActionMembershipProfileUpdate = "iam:membership-profile:update"
// Personal API Key actions
ActionPersonalAPIKeyCreate = "iam:personal-api-key:create"

View File

@@ -161,6 +161,7 @@ var IAMOwnerPolicy = policy.NewPolicy(
policy.Allow(
ActionMembershipProfileGet,
ActionMembershipProfileList,
ActionMembershipProfileUpdate,
).
WithSID("full-membership-profile-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
@@ -239,6 +240,7 @@ var IAMAdminPolicy = policy.NewPolicy(
policy.Allow(
ActionMembershipProfileGet,
ActionMembershipProfileList,
ActionMembershipProfileUpdate,
).
WithSID("membership-profile-admin-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),

View File

@@ -97,6 +97,16 @@ type (
FullName string
Role coredata.MembershipRole
}
UpdateProfileRequest struct {
ID gid.GID
FullName string
AdditionalEmailAddresses mail.Addrs
Kind coredata.MembershipProfileKind
Position **string
ContractStartDate **time.Time
ContractEndDate **time.Time
}
)
var (
@@ -124,6 +134,8 @@ var (
const (
TokenTypeAPIKey = "api_key"
NameMaxLength = 100
TitleMaxLength = 1000
ContentMaxLength = 5000
DefaultAttributeEmail = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
@@ -180,6 +192,22 @@ func (req UpdateOrganizationRequest) Validate() error {
return v.Error()
}
func (upr *UpdateProfileRequest) Validate() error {
v := validator.New()
v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
v.Check(upr.Kind, "kind", validator.OneOfSlice(coredata.PeopleKinds()))
v.Check(upr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength))
v.CheckEach(upr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) {
v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty())
})
v.Check(upr.Position, "position", validator.SafeText(TitleMaxLength))
v.Check(upr.ContractStartDate, "contract_start_date", validator.Before(upr.ContractEndDate))
v.Check(upr.ContractEndDate, "contract_end_date", validator.After(upr.ContractStartDate))
return v.Error()
}
func NewOrganizationService(svc *Service) *OrganizationService {
return &OrganizationService{Service: svc}
}
@@ -946,6 +974,57 @@ func (s *OrganizationService) ListMembers(
return page.NewPage(memberships, cursor), nil
}
func (s *OrganizationService) UpdateProfile(ctx context.Context, req *UpdateProfileRequest) (*coredata.MembershipProfile, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var (
scope = coredata.NewScopeFromObjectID(req.ID)
profile = &coredata.MembershipProfile{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := profile.LoadByID(ctx, conn, scope, req.ID); err != nil {
return fmt.Errorf("cannot load profile: %w", err)
}
profile.FullName = req.FullName
profile.Kind = req.Kind
profile.AdditionalEmailAddresses = req.AdditionalEmailAddresses
profile.Position = *req.Position
if req.ContractStartDate != nil {
profile.ContractStartDate = *req.ContractStartDate
}
if req.ContractEndDate != nil {
profile.ContractEndDate = *req.ContractEndDate
}
if profile.ContractStartDate != nil && profile.ContractEndDate != nil {
if profile.ContractEndDate.Before(*profile.ContractStartDate) {
return fmt.Errorf("contract end date must be after or equal to start date")
}
}
profile.UpdatedAt = time.Now()
return profile.Update(ctx, conn, scope)
},
)
if err != nil {
return nil, err
}
return profile, nil
}
func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) (*coredata.MembershipProfile, error) {
profile := &coredata.MembershipProfile{}

View File

@@ -98,6 +98,7 @@ type Mutation {
@session(required: PRESENT)
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload
@session(required: PRESENT)
updateProfile(input: UpdateProfileInput!): UpdateProfilePayload!
updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload
@session(required: PRESENT)
@@ -176,9 +177,20 @@ type Identity implements Node {
type MembershipProfile implements Node {
id: ID!
fullName: String!
additionalEmailAddresses: [EmailAddr!]!
kind: ProfileKind!
position: String
contractStartDate: Datetime
contractEndDate: Datetime
createdAt: Datetime!
updatedAt: Datetime!
identity: Identity @goField(forceResolver: true)
organization: Organization @goField(forceResolver: true)
# TODO: remove when memberships are under profile
membershipId: ID!
permission(action: String!): Boolean!
@goField(forceResolver: true)
@session(required: PRESENT)
@@ -229,6 +241,17 @@ type Organization implements Node {
@session(required: PRESENT)
}
enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") {
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee")
CONTRACTOR
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindContractor")
SERVICE_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindServiceAccount"
)
}
enum MembershipRole
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") {
OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner")
@@ -717,6 +740,16 @@ input InviteMemberInput {
role: MembershipRole!
}
input UpdateProfileInput {
id: ID!
fullName: String!
additionalEmailAddresses: [EmailAddr!]
kind: ProfileKind!
position: String @goField(omittable: true)
contractStartDate: Datetime @goField(omittable: true)
contractEndDate: Datetime @goField(omittable: true)
}
input UpdateMembershipInput {
organizationId: ID!
membershipId: ID!
@@ -868,6 +901,10 @@ type InviteMemberPayload {
invitationEdge: InvitationEdge!
}
type UpdateProfilePayload {
profile: MembershipProfile!
}
type UpdateMembershipPayload {
membership: Membership!
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,21 @@ import "go.probo.inc/probo/pkg/coredata"
func NewMembershipProfile(profile *coredata.MembershipProfile) *MembershipProfile {
return &MembershipProfile{
ID: profile.ID,
FullName: profile.FullName,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
ID: profile.ID,
FullName: profile.FullName,
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
Kind: profile.Kind,
Position: profile.Position,
ContractStartDate: profile.ContractStartDate,
ContractEndDate: profile.ContractEndDate,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
MembershipID: profile.MembershipID,
Identity: &Identity{
ID: profile.IdentityID,
},
Organization: &Organization{
ID: profile.OrganizationID,
},
}
}

View File

@@ -240,11 +240,19 @@ type MembershipEdge struct {
}
type MembershipProfile struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Permission bool `json:"permission"`
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses"`
Kind coredata.MembershipProfileKind `json:"kind"`
Position *string `json:"position,omitempty"`
ContractStartDate *time.Time `json:"contractStartDate,omitempty"`
ContractEndDate *time.Time `json:"contractEndDate,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Identity *Identity `json:"identity,omitempty"`
Organization *Organization `json:"organization,omitempty"`
MembershipID gid.GID `json:"membershipId"`
Permission bool `json:"permission"`
}
func (MembershipProfile) IsNode() {}
@@ -545,6 +553,20 @@ type UpdateOrganizationPayload struct {
Organization *Organization `json:"organization,omitempty"`
}
type UpdateProfileInput struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses,omitempty"`
Kind coredata.MembershipProfileKind `json:"kind"`
Position graphql.Omittable[*string] `json:"position,omitempty"`
ContractStartDate graphql.Omittable[*time.Time] `json:"contractStartDate,omitempty"`
ContractEndDate graphql.Omittable[*time.Time] `json:"contractEndDate,omitempty"`
}
type UpdateProfilePayload struct {
Profile *MembershipProfile `json:"profile"`
}
type UpdateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"`
SamlConfigurationID gid.GID `json:"samlConfigurationId"`

View File

@@ -349,6 +349,53 @@ func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *type
return nil, gqlutils.Internal(ctx)
}
// Identity is the resolver for the identity field.
func (r *membershipProfileResolver) Identity(ctx context.Context, obj *types.MembershipProfile) (*types.Identity, error) {
if err := r.authorize(
ctx,
obj.Identity.ID,
iam.ActionIdentityGet,
authz.WithAttr("organization_id", obj.Organization.ID.String()),
); err != nil {
return nil, err
}
if gqlutils.OnlyIDSelected(ctx) {
return &types.Identity{
ID: obj.Identity.ID,
}, nil
}
identity, err := r.iam.AccountService.GetIdentity(ctx, obj.Identity.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get identity for membership", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewIdentity(identity), nil
}
// Organization is the resolver for the organization field.
func (r *membershipProfileResolver) Organization(ctx context.Context, obj *types.MembershipProfile) (*types.Organization, error) {
if err := r.authorize(ctx, obj.Organization.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil {
return nil, err
}
if gqlutils.OnlyIDSelected(ctx) {
return &types.Organization{
ID: obj.Organization.ID,
}, nil
}
organization, err := r.iam.OrganizationService.GetOrganizationForMembership(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get organization for membership", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewOrganization(organization), nil
}
// Permission is the resolver for the permission field.
func (r *membershipProfileResolver) Permission(ctx context.Context, obj *types.MembershipProfile, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -991,6 +1038,34 @@ func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.Del
return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil
}
// UpdateProfile is the resolver for the updateProfile field.
func (r *mutationResolver) UpdateProfile(ctx context.Context, input types.UpdateProfileInput) (*types.UpdateProfilePayload, error) {
if err := r.authorize(ctx, input.ID, iam.ActionMembershipProfileUpdate); err != nil {
return nil, err
}
profile, err := r.iam.OrganizationService.UpdateProfile(
ctx,
&iam.UpdateProfileRequest{
ID: input.ID,
FullName: input.FullName,
AdditionalEmailAddresses: input.AdditionalEmailAddresses,
Kind: input.Kind,
Position: gqlutils.UnwrapOmittable(input.Position),
ContractStartDate: gqlutils.UnwrapOmittable(input.ContractStartDate),
ContractEndDate: gqlutils.UnwrapOmittable(input.ContractEndDate),
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateProfilePayload{
Profile: types.NewMembershipProfile(profile),
}, nil
}
// UpdateMembership is the resolver for the updateMembership field.
func (r *mutationResolver) UpdateMembership(ctx context.Context, input types.UpdateMembershipInput) (*types.UpdateMembershipPayload, error) {
if err := r.authorize(ctx, input.MembershipID, iam.ActionMembershipUpdate); err != nil {
@@ -1494,6 +1569,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewSession(session), nil
}
case coredata.MembershipProfileEntityType:
action = iam.ActionMembershipGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
profile, err := r.iam.OrganizationService.GetProfile(ctx, id)
if err != nil {
return nil, err
}
return types.NewMembershipProfile(profile), nil
}
case coredata.MembershipEntityType:
action = iam.ActionMembershipGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {

View File

@@ -69,7 +69,7 @@ enum EvidenceState
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
}
enum MembershipProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") {
enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") {
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee")
CONTRACTOR
@@ -1846,7 +1846,7 @@ type Profile implements Node {
fullName: String!
emailAddress: EmailAddr!
additionalEmailAddresses: [EmailAddr!]!
kind: MembershipProfileKind!
kind: ProfileKind!
position: String
contractStartDate: Datetime
contractEndDate: Datetime

View File

@@ -10347,7 +10347,7 @@ enum EvidenceState
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
}
enum MembershipProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") {
enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") {
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee")
CONTRACTOR
@@ -12124,7 +12124,7 @@ type Profile implements Node {
fullName: String!
emailAddress: EmailAddr!
additionalEmailAddresses: [EmailAddr!]!
kind: MembershipProfileKind!
kind: ProfileKind!
position: String
contractStartDate: Datetime
contractEndDate: Datetime
@@ -44174,7 +44174,7 @@ func (ec *executionContext) _Profile_kind(ctx context.Context, field graphql.Col
return obj.Kind, nil
},
nil,
ec.marshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind,
ec.marshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind,
true,
true,
)
@@ -44187,7 +44187,7 @@ func (ec *executionContext) fieldContext_Profile_kind(_ context.Context, field g
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type MembershipProfileKind does not have child fields")
return nil, errors.New("field of type ProfileKind does not have child fields")
},
}
return fc, nil
@@ -94083,36 +94083,6 @@ var (
}
)
func (ec *executionContext) unmarshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind(ctx context.Context, v any) (coredata.MembershipProfileKind, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind[tmp]
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipProfileKind) graphql.Marshaler {
_ = sel
res := graphql.MarshalString(marshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind[v])
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
}
return res
}
var (
unmarshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind = map[string]coredata.MembershipProfileKind{
"EMPLOYEE": coredata.MembershipProfileKindEmployee,
"CONTRACTOR": coredata.MembershipProfileKindContractor,
"SERVICE_ACCOUNT": coredata.MembershipProfileKindServiceAccount,
}
marshalNMembershipProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind = map[coredata.MembershipProfileKind]string{
coredata.MembershipProfileKindEmployee: "EMPLOYEE",
coredata.MembershipProfileKindContractor: "CONTRACTOR",
coredata.MembershipProfileKindServiceAccount: "SERVICE_ACCOUNT",
}
)
func (ec *executionContext) marshalNNode2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐNode(ctx context.Context, sel ast.SelectionSet, v types.Node) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
@@ -94885,6 +94855,36 @@ func (ec *executionContext) marshalNProfileEdge2ᚖgoᚗproboᚗincᚋproboᚋpk
return ec._ProfileEdge(ctx, sel, v)
}
func (ec *executionContext) unmarshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind(ctx context.Context, v any) (coredata.MembershipProfileKind, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind[tmp]
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipProfileKind) graphql.Marshaler {
_ = sel
res := graphql.MarshalString(marshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind[v])
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
}
return res
}
var (
unmarshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind = map[string]coredata.MembershipProfileKind{
"EMPLOYEE": coredata.MembershipProfileKindEmployee,
"CONTRACTOR": coredata.MembershipProfileKindContractor,
"SERVICE_ACCOUNT": coredata.MembershipProfileKindServiceAccount,
}
marshalNProfileKind2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileKind = map[coredata.MembershipProfileKind]string{
coredata.MembershipProfileKindEmployee: "EMPLOYEE",
coredata.MembershipProfileKindContractor: "CONTRACTOR",
coredata.MembershipProfileKindServiceAccount: "SERVICE_ACCOUNT",
}
)
func (ec *executionContext) unmarshalNProfileOrderField2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileOrderField(ctx context.Context, v any) (coredata.MembershipProfileOrderField, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNProfileOrderField2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipProfileOrderField[tmp]