Refactor invitation system
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -600,3 +600,107 @@ func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassw
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) SignupFromInvitation(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
password string,
|
||||
fullName string,
|
||||
) (*coredata.User, *coredata.Session, error) {
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
s.tokenSecret,
|
||||
"organization_invitation",
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, &ErrInvalidTokenType{"invalid invitation token"}
|
||||
}
|
||||
invitationData := payload.Data
|
||||
|
||||
if len(password) < 8 || len(password) > 128 {
|
||||
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(invitationData.Email); err != nil {
|
||||
return nil, nil, &ErrInvalidEmail{invitationData.Email}
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
fullName = invitationData.FullName
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
return nil, nil, &ErrInvalidFullName{fullName}
|
||||
}
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(password))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
var user *coredata.User
|
||||
var session *coredata.Session
|
||||
|
||||
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
|
||||
var errInvitationNotFound *coredata.ErrInvitationNotFound
|
||||
if errors.As(err, &errInvitationNotFound) {
|
||||
return fmt.Errorf("invitation was deleted or no longer exists")
|
||||
}
|
||||
return fmt.Errorf("cannot load invitation: %w", err)
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
return fmt.Errorf("invitation already accepted")
|
||||
}
|
||||
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
return fmt.Errorf("invitation expired")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user = &coredata.User{
|
||||
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||
EmailAddress: invitationData.Email,
|
||||
HashedPassword: hashedPassword,
|
||||
EmailAddressVerified: true,
|
||||
FullName: fullName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||
}
|
||||
return fmt.Errorf("cannot insert user: %w", err)
|
||||
}
|
||||
|
||||
session = &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
@@ -41,6 +42,14 @@ type (
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
TenantAuthzService struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
scope coredata.Scoper
|
||||
}
|
||||
|
||||
Role string
|
||||
)
|
||||
|
||||
@@ -78,6 +87,18 @@ func NewService(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
|
||||
return &TenantAuthzService{
|
||||
pg: s.pg,
|
||||
hostname: s.hostname,
|
||||
tokenSecret: s.tokenSecret,
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because it operates across tenants
|
||||
// and doesn't require tenant-scoped access.
|
||||
func (s *Service) GetAllUserOrganizations(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
@@ -98,6 +119,8 @@ func (s *Service) GetAllUserOrganizations(
|
||||
return organizations, err
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because it operates across tenants
|
||||
// and doesn't require tenant-scoped access.
|
||||
func (s *Service) GetUserOrganizations(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
@@ -115,7 +138,253 @@ func (s *Service) GetUserOrganizations(
|
||||
return organizations, err
|
||||
}
|
||||
|
||||
func (s *Service) GetAllOrganizationInvitations(
|
||||
// This method is on Service (not TenantAuthzService) because the user accepting
|
||||
// the invitation doesn't have tenant access yet.
|
||||
func (s *Service) AcceptInvitation(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid invitation token: %w", err)
|
||||
}
|
||||
invitationData := payload.Data
|
||||
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
|
||||
var errInvitationNotFound *coredata.ErrInvitationNotFound
|
||||
if errors.As(err, &errInvitationNotFound) {
|
||||
return fmt.Errorf("invitation was deleted or no longer exists")
|
||||
}
|
||||
return fmt.Errorf("cannot load invitation: %w", err)
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
return fmt.Errorf("invitation already accepted")
|
||||
}
|
||||
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
return fmt.Errorf("invitation expired")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
|
||||
|
||||
membership := &coredata.Membership{
|
||||
ID: membershipID,
|
||||
UserID: userID,
|
||||
OrganizationID: invitation.OrganizationID,
|
||||
Role: invitation.Role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := membership.Create(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
invitation.AcceptedAt = &now
|
||||
if err := invitation.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user accepting
|
||||
// the invitation doesn't have tenant access yet.
|
||||
func (s *Service) AcceptInvitationByID(
|
||||
ctx context.Context,
|
||||
invitationID gid.GID,
|
||||
userID gid.GID,
|
||||
) (*coredata.Invitation, error) {
|
||||
var acceptedInvitation *coredata.Invitation
|
||||
scope := coredata.NewScope(invitationID.TenantID())
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, tx, scope, invitationID); err != nil {
|
||||
var errInvitationNotFound *coredata.ErrInvitationNotFound
|
||||
if errors.As(err, &errInvitationNotFound) {
|
||||
return fmt.Errorf("invitation was deleted or no longer exists")
|
||||
}
|
||||
return fmt.Errorf("cannot load invitation: %w", err)
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
return fmt.Errorf("invitation already accepted")
|
||||
}
|
||||
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
return fmt.Errorf("invitation expired")
|
||||
}
|
||||
|
||||
user := &coredata.User{}
|
||||
if err := user.LoadByID(ctx, tx, userID); err != nil {
|
||||
return fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
if invitation.Email != user.EmailAddress {
|
||||
return fmt.Errorf("invitation email does not match user email")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
|
||||
|
||||
membership := &coredata.Membership{
|
||||
ID: membershipID,
|
||||
UserID: userID,
|
||||
OrganizationID: invitation.OrganizationID,
|
||||
Role: invitation.Role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := membership.Create(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
invitation.AcceptedAt = &now
|
||||
if err := invitation.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
|
||||
}
|
||||
|
||||
acceptedInvitation = invitation
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return acceptedInvitation, nil
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user viewing
|
||||
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
|
||||
func (s *Service) GetUserInvitations(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
cursor *page.Cursor[coredata.InvitationOrderField],
|
||||
filter *coredata.InvitationFilter,
|
||||
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
|
||||
var invitations coredata.Invitations
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitations.LoadByEmail(ctx, conn, email, cursor, filter); err != nil {
|
||||
return fmt.Errorf("failed to load invitations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(invitations, cursor), nil
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user viewing
|
||||
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
|
||||
func (s *Service) CountUserInvitations(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
filter *coredata.InvitationFilter,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var invitations coredata.Invitations
|
||||
var err error
|
||||
count, err = invitations.CountByEmail(ctx, conn, email, filter)
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user viewing
|
||||
// the invitation organization doesn't have tenant access yet.
|
||||
func (s *Service) GetOrganizationByInvitationID(
|
||||
ctx context.Context,
|
||||
invitationID gid.GID,
|
||||
) (*coredata.Organization, error) {
|
||||
scope := coredata.NewScope(invitationID.TenantID())
|
||||
|
||||
var organization coredata.Organization
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var invitation coredata.Invitation
|
||||
if err := invitation.LoadByID(ctx, conn, scope, invitationID); err != nil {
|
||||
return fmt.Errorf("failed to load invitation: %w", err)
|
||||
}
|
||||
|
||||
if err := organization.LoadByID(ctx, conn, scope, invitation.OrganizationID); err != nil {
|
||||
return fmt.Errorf("failed to load organization: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &organization, nil
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user added to the organization
|
||||
// doesn't have tenant access yet
|
||||
func (s *Service) AddUserToOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
role string,
|
||||
) error {
|
||||
now := time.Now()
|
||||
tenantID := orgID.TenantID()
|
||||
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
|
||||
scope := coredata.NewScope(tenantID)
|
||||
|
||||
membership := &coredata.Membership{
|
||||
ID: membershipID,
|
||||
UserID: userID,
|
||||
OrganizationID: orgID,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.Create(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[coredata.InvitationOrderField],
|
||||
@@ -125,7 +394,7 @@ func (s *Service) GetAllOrganizationInvitations(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitations.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
|
||||
if err := invitations.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
|
||||
return fmt.Errorf("failed to load organization invitations: %w", err)
|
||||
}
|
||||
|
||||
@@ -139,7 +408,7 @@ func (s *Service) GetAllOrganizationInvitations(
|
||||
return page.NewPage(invitations, cursor), nil
|
||||
}
|
||||
|
||||
func (s *Service) CountOrganizationInvitations(
|
||||
func (s *TenantAuthzService) CountOrganizationInvitations(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
@@ -149,7 +418,7 @@ func (s *Service) CountOrganizationInvitations(
|
||||
func(conn pg.Conn) error {
|
||||
var invitations coredata.Invitations
|
||||
var err error
|
||||
count, err = invitations.CountByOrganizationID(ctx, conn, orgID)
|
||||
count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
@@ -160,7 +429,27 @@ func (s *Service) CountOrganizationInvitations(
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteInvitation(
|
||||
func (s *TenantAuthzService) GetInvitationByID(
|
||||
ctx context.Context,
|
||||
invitationID gid.GID,
|
||||
) (*coredata.Invitation, error) {
|
||||
invitation := &coredata.Invitation{}
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
|
||||
return fmt.Errorf("failed to load invitation: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (s *TenantAuthzService) DeleteInvitation(
|
||||
ctx context.Context,
|
||||
invitationID gid.GID,
|
||||
) error {
|
||||
@@ -168,11 +457,11 @@ func (s *Service) DeleteInvitation(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, conn, invitationID); err != nil {
|
||||
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
|
||||
return fmt.Errorf("failed to load invitation: %w", err)
|
||||
}
|
||||
|
||||
if err := invitation.Delete(ctx, conn); err != nil {
|
||||
if err := invitation.Delete(ctx, conn, s.scope); err != nil {
|
||||
return fmt.Errorf("failed to delete invitation: %w", err)
|
||||
}
|
||||
|
||||
@@ -181,7 +470,7 @@ func (s *Service) DeleteInvitation(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) GetAllOrganizationMemberships(
|
||||
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[coredata.MembershipOrderField],
|
||||
@@ -191,7 +480,7 @@ func (s *Service) GetAllOrganizationMemberships(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := memberships.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
|
||||
if err := memberships.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
|
||||
return fmt.Errorf("failed to load organization memberships: %w", err)
|
||||
}
|
||||
|
||||
@@ -205,7 +494,7 @@ func (s *Service) GetAllOrganizationMemberships(
|
||||
return page.NewPage(memberships, cursor), nil
|
||||
}
|
||||
|
||||
func (s *Service) CountOrganizationMemberships(
|
||||
func (s *TenantAuthzService) CountOrganizationMemberships(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
@@ -215,7 +504,7 @@ func (s *Service) CountOrganizationMemberships(
|
||||
func(conn pg.Conn) error {
|
||||
var memberships coredata.Memberships
|
||||
var err error
|
||||
count, err = memberships.CountByOrganizationID(ctx, conn, orgID)
|
||||
count, err = memberships.CountByOrganizationID(ctx, conn, s.scope, orgID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
@@ -226,7 +515,28 @@ func (s *Service) CountOrganizationMemberships(
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) CanUserAccessOrganization(
|
||||
func (s *TenantAuthzService) CountOrganizationUsers(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var users coredata.Users
|
||||
var err error
|
||||
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count users: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *TenantAuthzService) CanUserAccessOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
@@ -238,7 +548,7 @@ func (s *Service) CanUserAccessOrganization(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
|
||||
if _, ok := err.(coredata.ErrMembershipNotFound); ok {
|
||||
return nil // Not an error, just no access
|
||||
}
|
||||
@@ -256,7 +566,7 @@ func (s *Service) CanUserAccessOrganization(
|
||||
return haveAccess, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserRoleInOrganization(
|
||||
func (s *TenantAuthzService) GetUserRoleInOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
@@ -266,7 +576,7 @@ func (s *Service) GetUserRoleInOrganization(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
|
||||
return fmt.Errorf("failed to get user role: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -280,7 +590,7 @@ func (s *Service) GetUserRoleInOrganization(
|
||||
return membership.Role, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveMemberFromOrganization(
|
||||
func (s *TenantAuthzService) RemoveMemberFromOrganization(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
memberID gid.GID,
|
||||
@@ -290,7 +600,7 @@ func (s *Service) RemoveMemberFromOrganization(
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := membership.LoadByID(ctx, tx, memberID); err != nil {
|
||||
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
|
||||
return fmt.Errorf("failed to load membership: %w", err)
|
||||
}
|
||||
|
||||
@@ -298,7 +608,7 @@ func (s *Service) RemoveMemberFromOrganization(
|
||||
return fmt.Errorf("membership does not belong to organization")
|
||||
}
|
||||
|
||||
if err := membership.Delete(ctx, tx); err != nil {
|
||||
if err := membership.Delete(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("failed to delete membership: %w", err)
|
||||
}
|
||||
|
||||
@@ -307,32 +617,7 @@ func (s *Service) RemoveMemberFromOrganization(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) AddUserToOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
role string,
|
||||
) error {
|
||||
membership := &coredata.Membership{
|
||||
UserID: userID,
|
||||
OrganizationID: orgID,
|
||||
Role: role,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.Create(ctx, conn); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserRole(
|
||||
func (s *TenantAuthzService) UpdateUserRole(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
@@ -342,14 +627,14 @@ func (s *Service) UpdateUserRole(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
membership := &coredata.Membership{}
|
||||
if err := membership.LoadByUserAndOrg(ctx, tx, userID, orgID); err != nil {
|
||||
if err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, orgID); err != nil {
|
||||
return fmt.Errorf("failed to find membership: %w", err)
|
||||
}
|
||||
|
||||
membership.Role = newRole
|
||||
membership.UpdatedAt = time.Now()
|
||||
|
||||
if err := membership.Update(ctx, tx); err != nil {
|
||||
if err := membership.Update(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("failed to update user role: %w", err)
|
||||
}
|
||||
|
||||
@@ -358,7 +643,7 @@ func (s *Service) UpdateUserRole(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) InviteUserToOrganization(
|
||||
func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
emailAddress string,
|
||||
@@ -380,12 +665,11 @@ func (s *Service) InviteUserToOrganization(
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
scope := coredata.NewScope(organizationID.TenantID())
|
||||
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
|
||||
if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
|
||||
return fmt.Errorf("failed to load organization: %w", err)
|
||||
}
|
||||
|
||||
invitationID := gid.New(organizationID.TenantID(), coredata.InvitationEntityType)
|
||||
invitationID := gid.New(s.scope.GetTenantID(), coredata.InvitationEntityType)
|
||||
now := time.Now()
|
||||
invitation = &coredata.Invitation{
|
||||
ID: invitationID,
|
||||
@@ -397,19 +681,20 @@ func (s *Service) InviteUserToOrganization(
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
var err error
|
||||
if userExists {
|
||||
membership := &coredata.Membership{
|
||||
UserID: user.ID,
|
||||
OrganizationID: organizationID,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
err = invitationEmailBodyTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"OrganizationName": organization.Name,
|
||||
"InvitationURL": fmt.Sprintf("https://%s/", s.hostname),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
if err := membership.Create(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
invitation.AcceptedAt = &now
|
||||
} else {
|
||||
invitationData := coredata.InvitationData{
|
||||
InvitationID: invitationID,
|
||||
@@ -429,32 +714,31 @@ func (s *Service) InviteUserToOrganization(
|
||||
return fmt.Errorf("failed to generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = invitationEmailBodyTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": fullName,
|
||||
"OrganizationName": organization.Name,
|
||||
"InvitationURL": fmt.Sprintf("https://%s/auth/confirm-invitation?token=%s", s.hostname, invitationToken),
|
||||
"InvitationURL": fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName)),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
fullName,
|
||||
emailAddress,
|
||||
invitationEmailSubject,
|
||||
body.String(),
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := invitation.Create(ctx, tx); err != nil {
|
||||
email := coredata.NewEmail(
|
||||
fullName,
|
||||
emailAddress,
|
||||
invitationEmailSubject,
|
||||
body.String(),
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
if err := invitation.Create(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("cannot create invitation: %w", err)
|
||||
}
|
||||
|
||||
@@ -468,66 +752,8 @@ func (s *Service) InviteUserToOrganization(
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptInvitation(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid invitation token: %w", err)
|
||||
}
|
||||
invitationData := payload.Data
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, tx, invitationData.InvitationID); err != nil {
|
||||
var errInvitationNotFound *coredata.ErrInvitationNotFound
|
||||
if errors.As(err, &errInvitationNotFound) {
|
||||
return fmt.Errorf("invitation was deleted or no longer exists")
|
||||
}
|
||||
return fmt.Errorf("cannot load invitation: %w", err)
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
return fmt.Errorf("invitation already accepted")
|
||||
}
|
||||
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
return fmt.Errorf("invitation expired")
|
||||
}
|
||||
|
||||
membership := &coredata.Membership{
|
||||
UserID: userID,
|
||||
OrganizationID: invitation.OrganizationID,
|
||||
Role: invitation.Role,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := membership.Create(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
invitation.AcceptedAt = &now
|
||||
if err := invitation.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// This is a placeholder for future permission system
|
||||
func (s *Service) HasPermission(
|
||||
func (s *TenantAuthzService) HasPermission(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
@@ -538,22 +764,3 @@ func (s *Service) HasPermission(
|
||||
// In the future, this will check specific permissions based on role
|
||||
return s.CanUserAccessOrganization(ctx, userID, orgID)
|
||||
}
|
||||
|
||||
func (s *Service) ListUserInvitations(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) ([]*coredata.Invitation, error) {
|
||||
var invitations coredata.Invitations
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitations.LoadByEmail(ctx, conn, email); err != nil {
|
||||
return fmt.Errorf("failed to load invitations: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
return invitations, err
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ JOIN snapshot_vendors sv ON sv.source_id = av.vendor_id
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"snapshot_id": snapshotID,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ JOIN snapshot_vendors sv ON sv.source_id = dv.vendor_id
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"snapshot_id": snapshotID,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
|
||||
@@ -81,17 +81,17 @@ func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
|
||||
func (i *Invitation) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
INSERT INTO authz_invitations (
|
||||
id, organization_id, email, full_name, role, expires_at, created_at
|
||||
tenant_id, id, organization_id, email, full_name, role, expires_at, created_at
|
||||
) VALUES (
|
||||
@id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
|
||||
@tenant_id, @id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"id": i.ID,
|
||||
"organization_id": i.OrganizationID,
|
||||
"email": i.Email,
|
||||
@@ -109,21 +109,24 @@ func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access invitations across all tenants for authentication purposes.
|
||||
func (i *Invitation) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE id = @id
|
||||
WHERE id = @id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": id,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -142,18 +145,20 @@ func (i *Invitation) LoadByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
|
||||
func (i *Invitation) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
UPDATE authz_invitations
|
||||
SET accepted_at = @accepted_at
|
||||
WHERE id = @id
|
||||
WHERE id = @id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
"accepted_at": i.AcceptedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -167,16 +172,18 @@ func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
DELETE FROM authz_invitations
|
||||
WHERE id = @id
|
||||
WHERE id = @id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -190,21 +197,30 @@ func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant scope is not applied because this is used to query invitations across all tenants
|
||||
// for a user who doesn't have tenant access yet (before accepting an invitation).
|
||||
func (i *Invitations) LoadByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
email string,
|
||||
cursor *page.Cursor[InvitationOrderField],
|
||||
filter *InvitationFilter,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE email = @email AND accepted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
WHERE email = @email
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, filter.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email": email,
|
||||
}
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -223,19 +239,23 @@ func (i *Invitations) LoadByEmail(
|
||||
func (i *Invitations) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[InvitationOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE organization_id = @organization_id
|
||||
WHERE organization_id = @organization_id AND %s
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": orgID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
@@ -255,6 +275,7 @@ func (i *Invitations) LoadByOrganizationID(
|
||||
func (i *Invitations) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
@@ -263,10 +284,51 @@ SELECT
|
||||
FROM
|
||||
authz_invitations
|
||||
WHERE
|
||||
organization_id = @organization_id
|
||||
organization_id = @organization_id AND %s
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": orgID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count invitations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Tenant scope is not applied because this is used to count invitations across all tenants
|
||||
// for a user who doesn't have tenant access yet (before accepting an invitation).
|
||||
func (i *Invitations) CountByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
email string,
|
||||
filter *InvitationFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
authz_invitations
|
||||
WHERE
|
||||
email = @email
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email": email,
|
||||
}
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
|
||||
49
pkg/coredata/invitation_filter.go
Normal file
49
pkg/coredata/invitation_filter.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
InvitationFilter struct {
|
||||
onlyPending *bool
|
||||
}
|
||||
)
|
||||
|
||||
func NewInvitationFilter(onlyPending *bool) *InvitationFilter {
|
||||
return &InvitationFilter{
|
||||
onlyPending: onlyPending,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *InvitationFilter) SQLArguments() pgx.NamedArgs {
|
||||
return pgx.NamedArgs{
|
||||
"only_pending": f.onlyPending,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *InvitationFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
CASE
|
||||
WHEN @only_pending::boolean IS NOT NULL AND @only_pending::boolean = true THEN
|
||||
(accepted_at IS NULL AND expires_at > NOW())
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -76,28 +76,20 @@ func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
|
||||
func (m *Membership) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(o.tenant_id), @entity_type),
|
||||
@user_id,
|
||||
@organization_id,
|
||||
@role,
|
||||
@created_at,
|
||||
@updated_at
|
||||
FROM organizations o
|
||||
WHERE o.id = @organization_id
|
||||
INSERT INTO authz_memberships (tenant_id, id, user_id, organization_id, role, created_at, updated_at)
|
||||
VALUES (@tenant_id, @id, @user_id, @organization_id, @role, @created_at, @updated_at)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"id": m.ID,
|
||||
"user_id": m.UserID,
|
||||
"organization_id": m.OrganizationID,
|
||||
"role": m.Role,
|
||||
"created_at": m.CreatedAt,
|
||||
"updated_at": m.UpdatedAt,
|
||||
"entity_type": MembershipEntityType,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
@@ -116,10 +108,10 @@ func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
membershipID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
@@ -134,12 +126,15 @@ func (m *Membership) LoadByID(
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.id = @membership_id
|
||||
WHERE m.id = @membership_id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"membership_id": membershipID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -158,10 +153,10 @@ func (m *Membership) LoadByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByUserAndOrg(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
) error {
|
||||
@@ -177,13 +172,16 @@ func (m *Membership) LoadByUserAndOrg(
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.user_id = @user_id AND m.organization_id = @organization_id
|
||||
WHERE m.user_id = @user_id AND m.organization_id = @organization_id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": userID,
|
||||
"organization_id": orgID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -202,19 +200,21 @@ func (m *Membership) LoadByUserAndOrg(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
|
||||
func (m *Membership) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
UPDATE authz_memberships
|
||||
SET role = @role, updated_at = @updated_at
|
||||
WHERE id = @id
|
||||
WHERE id = @id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
"role": m.Role,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -228,16 +228,18 @@ func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
DELETE FROM authz_memberships
|
||||
WHERE id = @id
|
||||
WHERE id = @id AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -251,10 +253,10 @@ func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access all user's memberships across tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
@@ -272,11 +274,17 @@ FROM
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.user_id = @user_id
|
||||
AND %s
|
||||
ORDER BY
|
||||
m.created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": userID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
@@ -292,10 +300,10 @@ ORDER BY
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[MembershipOrderField],
|
||||
) error {
|
||||
@@ -315,11 +323,15 @@ JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.organization_id = @organization_id
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
@@ -339,14 +351,19 @@ WHERE
|
||||
func (m *Memberships) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM authz_memberships
|
||||
WHERE organization_id = @organization_id
|
||||
WHERE organization_id = @organization_id AND %s
|
||||
`
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
row := conn.QueryRow(ctx, query, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ CREATE TYPE authz_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
|
||||
-- Create authz_memberships table with id as primary key
|
||||
CREATE TABLE authz_memberships (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
role authz_role NOT NULL,
|
||||
@@ -19,6 +20,7 @@ CREATE TABLE authz_memberships (
|
||||
-- Create authz_invitations table
|
||||
CREATE TABLE authz_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
@@ -29,8 +31,9 @@ CREATE TABLE authz_invitations (
|
||||
);
|
||||
|
||||
-- Copy data from users_organizations to authz_memberships
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
INSERT INTO authz_memberships (tenant_id, id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
organizations.tenant_id,
|
||||
generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id,
|
||||
users_organizations.user_id,
|
||||
users_organizations.organization_id,
|
||||
|
||||
@@ -32,7 +32,6 @@ type (
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Kind PeopleKind `db:"kind"`
|
||||
UserID *gid.GID `db:"user_id"`
|
||||
FullName string `db:"full_name"`
|
||||
PrimaryEmailAddress string `db:"primary_email_address"`
|
||||
AdditionalEmailAddresses []string `db:"additional_email_addresses"`
|
||||
@@ -78,7 +77,6 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
user_id,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
@@ -126,7 +124,6 @@ func (p *People) LoadByEmail(
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
user_id,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
@@ -167,58 +164,6 @@ func (p *People) LoadByEmail(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *People) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
user_id,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
position,
|
||||
contract_start_date,
|
||||
contract_end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
peoples
|
||||
WHERE
|
||||
%s
|
||||
AND user_id = @user_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query people: %w", err)
|
||||
}
|
||||
|
||||
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &ErrPeopleNotFound{Identifier: userID.String()}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect people: %w", err)
|
||||
}
|
||||
|
||||
*p = people
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p People) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -230,7 +175,6 @@ INSERT INTO
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
user_id,
|
||||
kind,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
@@ -245,7 +189,6 @@ VALUES (
|
||||
@tenant_id,
|
||||
@people_id,
|
||||
@organization_id,
|
||||
@user_id,
|
||||
@kind,
|
||||
@full_name,
|
||||
@primary_email_address,
|
||||
@@ -262,7 +205,6 @@ VALUES (
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"people_id": p.ID,
|
||||
"organization_id": p.OrganizationID,
|
||||
"user_id": p.UserID,
|
||||
"kind": p.Kind,
|
||||
"full_name": p.FullName,
|
||||
"primary_email_address": p.PrimaryEmailAddress,
|
||||
@@ -343,7 +285,6 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
user_id,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
@@ -390,7 +331,6 @@ func (p *People) Update(
|
||||
) error {
|
||||
q := `
|
||||
UPDATE peoples SET
|
||||
user_id = @user_id,
|
||||
full_name = @full_name,
|
||||
primary_email_address = @primary_email_address,
|
||||
additional_email_addresses = @additional_email_addresses,
|
||||
@@ -406,7 +346,6 @@ WHERE %s
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"people_id": p.ID,
|
||||
"user_id": p.UserID,
|
||||
"full_name": p.FullName,
|
||||
"primary_email_address": p.PrimaryEmailAddress,
|
||||
"additional_email_addresses": p.AdditionalEmailAddresses,
|
||||
@@ -447,7 +386,6 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
user_id,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
|
||||
@@ -115,6 +115,7 @@ WHERE
|
||||
func (u *Users) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
@@ -124,11 +125,14 @@ FROM
|
||||
users
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id AND %s
|
||||
)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
UserOrganization struct {
|
||||
UserID gid.GID `db:"user_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
UserOrganizations []*UserOrganization
|
||||
)
|
||||
|
||||
func (uo UserOrganization) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO users_organizations (user_id, organization_id, created_at)
|
||||
VALUES (@user_id, @organization_id, @created_at)
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"user_id": uo.UserID, "organization_id": uo.OrganizationID, "created_at": uo.CreatedAt})
|
||||
return err
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because user organizations are managed at the organization level and don't require tenant isolation.
|
||||
func (uo UserOrganization) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
q := `
|
||||
DELETE FROM users_organizations WHERE user_id = @user_id AND organization_id = @organization_id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"user_id": uo.UserID, "organization_id": uo.OrganizationID})
|
||||
return err
|
||||
}
|
||||
|
||||
func (uo *UserOrganizations) ForUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT user_id, organization_id, created_at FROM users_organizations WHERE user_id = @user_id
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"user_id": userID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userOrganizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[UserOrganization])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*uo = userOrganizations
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -32,7 +32,6 @@ type (
|
||||
|
||||
UpdatePeopleRequest struct {
|
||||
ID gid.GID
|
||||
UserID *gid.GID
|
||||
Kind *coredata.PeopleKind
|
||||
FullName *string
|
||||
PrimaryEmailAddress *string
|
||||
@@ -44,7 +43,6 @@ type (
|
||||
|
||||
CreatePeopleRequest struct {
|
||||
OrganizationID gid.GID
|
||||
UserID *gid.GID
|
||||
FullName string
|
||||
PrimaryEmailAddress string
|
||||
AdditionalEmailAddresses []string
|
||||
@@ -75,26 +73,6 @@ func (s PeopleService) Get(
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func (s PeopleService) GetByUserID(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) (*coredata.People, error) {
|
||||
people := &coredata.People{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return people.LoadByUserID(ctx, conn, s.svc.scope, userID)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func (s PeopleService) CountForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
@@ -164,10 +142,6 @@ func (s PeopleService) Update(
|
||||
return fmt.Errorf("cannot load people: %w", err)
|
||||
}
|
||||
|
||||
if req.UserID != nil {
|
||||
people.UserID = req.UserID
|
||||
}
|
||||
|
||||
if req.Kind != nil {
|
||||
people.Kind = *req.Kind
|
||||
}
|
||||
@@ -234,7 +208,6 @@ func (s PeopleService) Create(
|
||||
FullName: req.FullName,
|
||||
PrimaryEmailAddress: req.PrimaryEmailAddress,
|
||||
AdditionalEmailAddresses: req.AdditionalEmailAddresses,
|
||||
UserID: req.UserID,
|
||||
Position: req.Position,
|
||||
ContractStartDate: req.ContractStartDate,
|
||||
ContractEndDate: req.ContractEndDate,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
type (
|
||||
InvitationConfirmationRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
InvitationConfirmationResponse struct {
|
||||
}
|
||||
)
|
||||
|
||||
func InvitationConfirmationHandler(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req InvitationConfirmationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
authCfg.CookieSecret,
|
||||
authz.TokenTypeOrganizationInvitation,
|
||||
req.Token,
|
||||
)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid invitation token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := authSvc.SignUp(r.Context(), payload.Data.Email, req.Password, payload.Data.FullName)
|
||||
if err != nil {
|
||||
var errUserAlreadyExists *auth.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
user, err = authSvc.GetUserByEmail(r.Context(), payload.Data.Email)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to load existing user: %w", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = authzSvc.AcceptInvitation(r.Context(), req.Token, user.ID)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.RenderJSON(w, http.StatusOK, InvitationConfirmationResponse{})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -157,7 +158,7 @@ func NewMux(
|
||||
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
|
||||
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
|
||||
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
|
||||
r.Post("/auth/invitation", InvitationConfirmationHandler(authSvc, authzSvc, authCfg))
|
||||
r.Post("/auth/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
|
||||
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
||||
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||
|
||||
@@ -316,18 +317,28 @@ func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *pro
|
||||
return GetTenantService(ctx, r.proboSvc, tenantID)
|
||||
}
|
||||
|
||||
func (r *Resolver) AuthzService(ctx context.Context, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||
return GetTenantAuthzService(ctx, r.authzSvc, tenantID)
|
||||
}
|
||||
|
||||
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
return proboSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
return authzSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
||||
tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID)
|
||||
|
||||
if tenantIDs == nil {
|
||||
panic(fmt.Errorf("tenant not found"))
|
||||
}
|
||||
|
||||
for _, id := range *tenantIDs {
|
||||
if id == tenantID {
|
||||
return proboSvc.WithTenant(tenantID)
|
||||
}
|
||||
if !slices.Contains(*tenantIDs, tenantID) {
|
||||
panic(fmt.Errorf("tenant not found"))
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("tenant not found"))
|
||||
}
|
||||
|
||||
@@ -1465,6 +1465,10 @@ input InvitationOrder {
|
||||
field: InvitationOrderField!
|
||||
}
|
||||
|
||||
input InvitationFilter {
|
||||
onlyPending: Boolean
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentStatus
|
||||
}
|
||||
@@ -1572,6 +1576,7 @@ type Organization implements Node {
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: InvitationOrder
|
||||
filter: InvitationFilter
|
||||
): InvitationConnection! @goField(forceResolver: true)
|
||||
|
||||
connectors(
|
||||
@@ -1736,8 +1741,6 @@ type User implements Node {
|
||||
email: String!
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
|
||||
people(organizationId: ID!): People @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type Membership implements Node {
|
||||
@@ -1759,6 +1762,7 @@ type Invitation implements Node {
|
||||
expiresAt: Datetime!
|
||||
acceptedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type Connector implements Node {
|
||||
@@ -2295,6 +2299,15 @@ type Viewer {
|
||||
before: CursorKey
|
||||
orderBy: OrganizationOrder
|
||||
): OrganizationConnection! @goField(forceResolver: true)
|
||||
|
||||
invitations(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: InvitationOrder
|
||||
filter: InvitationFilter
|
||||
): InvitationConnection! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
# Connection Types
|
||||
@@ -2394,13 +2407,19 @@ type TrustCenterReferenceEdge {
|
||||
node: TrustCenterReference!
|
||||
}
|
||||
|
||||
type UserConnection {
|
||||
type UserConnection
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.UserConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [UserEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type MembershipConnection {
|
||||
type MembershipConnection
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.MembershipConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [MembershipEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -2709,7 +2728,10 @@ type File {
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type InvitationConnection {
|
||||
type InvitationConnection
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.InvitationConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [InvitationEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -2780,6 +2802,7 @@ type Mutation {
|
||||
# User mutations
|
||||
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
|
||||
inviteUser(input: InviteUserInput!): InviteUserPayload!
|
||||
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
|
||||
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
|
||||
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
|
||||
|
||||
@@ -3540,6 +3563,10 @@ input InviteUserInput {
|
||||
createPeople: Boolean!
|
||||
}
|
||||
|
||||
input AcceptInvitationInput {
|
||||
invitationId: ID!
|
||||
}
|
||||
|
||||
input DeleteInvitationInput {
|
||||
invitationId: ID!
|
||||
}
|
||||
@@ -4066,12 +4093,16 @@ type InviteUserPayload {
|
||||
invitationEdge: InvitationEdge!
|
||||
}
|
||||
|
||||
type AcceptInvitationPayload {
|
||||
invitation: Invitation!
|
||||
}
|
||||
|
||||
type DeleteInvitationPayload {
|
||||
deletedInvitationId: ID!
|
||||
}
|
||||
|
||||
type RemoveMemberPayload {
|
||||
success: Boolean!
|
||||
deletedMemberId: ID!
|
||||
}
|
||||
|
||||
input VendorRiskAssessmentOrder {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
75
pkg/server/api/console/v1/signup_from_invitation_handler.go
Normal file
75
pkg/server/api/console/v1/signup_from_invitation_handler.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
type (
|
||||
SignupFromInvitationRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
FullName string `json:"fullName"`
|
||||
}
|
||||
|
||||
SignupFromInvitationResponse struct {
|
||||
}
|
||||
)
|
||||
|
||||
func SignupFromInvitationHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req SignupFromInvitationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
user, session, err := authSvc.SignupFromInvitation(r.Context(), req.Token, req.Password, req.FullName)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
securecookie.Set(
|
||||
w,
|
||||
securecookie.DefaultConfig(
|
||||
authCfg.CookieName,
|
||||
authCfg.CookieSecret,
|
||||
),
|
||||
session.ID.String(),
|
||||
)
|
||||
|
||||
httpserver.RenderJSON(
|
||||
w,
|
||||
http.StatusOK,
|
||||
SignUpResponse{
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Email: user.EmailAddress,
|
||||
FullName: user.FullName,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,28 @@ package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.InvitationOrderField]) *InvitationConnection {
|
||||
type (
|
||||
InvitationConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*InvitationEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filter *InvitationFilter
|
||||
}
|
||||
)
|
||||
|
||||
func NewInvitationConnection(
|
||||
p *page.Page[*coredata.Invitation, coredata.InvitationOrderField],
|
||||
resolver any,
|
||||
parentID gid.GID,
|
||||
filter *InvitationFilter,
|
||||
) *InvitationConnection {
|
||||
var edges = make([]*InvitationEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
@@ -29,6 +47,9 @@ func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.Invitat
|
||||
return &InvitationConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
Resolver: resolver,
|
||||
ParentID: parentID,
|
||||
Filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,28 @@ package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
MembershipConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*MembershipEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
|
||||
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
|
||||
)
|
||||
|
||||
func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.MembershipOrderField]) *MembershipConnection {
|
||||
func NewMembershipConnection(
|
||||
p *page.Page[*coredata.Membership, coredata.MembershipOrderField],
|
||||
resolver any,
|
||||
parentID gid.GID,
|
||||
) *MembershipConnection {
|
||||
var edges = make([]*MembershipEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
@@ -33,6 +47,8 @@ func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.Members
|
||||
return &MembershipConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
Resolver: resolver,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ type Node interface {
|
||||
GetID() gid.GID
|
||||
}
|
||||
|
||||
type AcceptInvitationInput struct {
|
||||
InvitationID gid.GID `json:"invitationId"`
|
||||
}
|
||||
|
||||
type AcceptInvitationPayload struct {
|
||||
Invitation *Invitation `json:"invitation"`
|
||||
}
|
||||
|
||||
type AssessVendorInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
WebsiteURL string `json:"websiteUrl"`
|
||||
@@ -1200,29 +1208,28 @@ type ImportMeasurePayload struct {
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID gid.GID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Organization *Organization `json:"organization"`
|
||||
}
|
||||
|
||||
func (Invitation) IsNode() {}
|
||||
func (this Invitation) GetID() gid.GID { return this.ID }
|
||||
|
||||
type InvitationConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*InvitationEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type InvitationEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Invitation `json:"node"`
|
||||
}
|
||||
|
||||
type InvitationFilter struct {
|
||||
OnlyPending *bool `json:"onlyPending,omitempty"`
|
||||
}
|
||||
|
||||
type InvitationOrder struct {
|
||||
Direction page.OrderDirection `json:"direction"`
|
||||
Field coredata.InvitationOrderField `json:"field"`
|
||||
@@ -1280,12 +1287,6 @@ type Membership struct {
|
||||
func (Membership) IsNode() {}
|
||||
func (this Membership) GetID() gid.GID { return this.ID }
|
||||
|
||||
type MembershipConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*MembershipEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type MembershipEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Membership `json:"node"`
|
||||
@@ -1493,7 +1494,7 @@ type RemoveMemberInput struct {
|
||||
}
|
||||
|
||||
type RemoveMemberPayload struct {
|
||||
Success bool `json:"success"`
|
||||
DeletedMemberID gid.GID `json:"deletedMemberId"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
@@ -2120,18 +2121,11 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
People *People `json:"people,omitempty"`
|
||||
}
|
||||
|
||||
func (User) IsNode() {}
|
||||
func (this User) GetID() gid.GID { return this.ID }
|
||||
|
||||
type UserConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*UserEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type UserEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *User `json:"node"`
|
||||
@@ -2316,4 +2310,5 @@ type Viewer struct {
|
||||
ID gid.GID `json:"id"`
|
||||
User *User `json:"user"`
|
||||
Organizations *OrganizationConnection `json:"organizations"`
|
||||
Invitations *InvitationConnection `json:"invitations"`
|
||||
}
|
||||
|
||||
@@ -16,14 +16,28 @@ package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
UserConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*UserEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
|
||||
UserOrderBy OrderBy[coredata.UserOrderField]
|
||||
)
|
||||
|
||||
func NewUserConnection(p *page.Page[*coredata.User, coredata.UserOrderField]) *UserConnection {
|
||||
func NewUserConnection(
|
||||
p *page.Page[*coredata.User, coredata.UserOrderField],
|
||||
resolver any,
|
||||
parentID gid.GID,
|
||||
) *UserConnection {
|
||||
var edges = make([]*UserEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
@@ -33,6 +47,8 @@ func NewUserConnection(p *page.Page[*coredata.User, coredata.UserOrderField]) *U
|
||||
return &UserConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
Resolver: resolver,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -891,25 +891,45 @@ func (r *frameworkConnectionResolver) TotalCount(ctx context.Context, obj *types
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *invitationResolver) Organization(ctx context.Context, obj *types.Invitation) (*types.Organization, error) {
|
||||
organization, err := r.authzSvc.GetOrganizationByInvitationID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot load organization: %w", err))
|
||||
}
|
||||
|
||||
return types.NewOrganization(organization), nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
switch obj.Resolver.(type) {
|
||||
case *organizationResolver:
|
||||
authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
|
||||
count, err := authzSvc.CountOrganizationInvitations(ctx, obj.ParentID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to count organization invitations: %w", err))
|
||||
}
|
||||
return count, nil
|
||||
case *viewerResolver:
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
panic(fmt.Errorf("no authenticated user"))
|
||||
}
|
||||
|
||||
invitationFilter := coredata.NewInvitationFilter(nil)
|
||||
if obj.Filter != nil {
|
||||
invitationFilter = coredata.NewInvitationFilter(obj.Filter.OnlyPending)
|
||||
}
|
||||
|
||||
count, err := r.authzSvc.CountUserInvitations(ctx, user.EmailAddress, invitationFilter)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to count user invitations: %w", err))
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationInvitations(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count invitations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// Evidences is the resolver for the evidences field.
|
||||
@@ -1052,23 +1072,17 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
switch obj.Resolver.(type) {
|
||||
case *organizationResolver:
|
||||
authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
|
||||
count, err := authzSvc.CountOrganizationMemberships(ctx, obj.ParentID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to count organization memberships: %w", err))
|
||||
}
|
||||
return count, nil
|
||||
default:
|
||||
panic(fmt.Errorf("unknown resolver type for membership connection"))
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CreateOrganization is the resolver for the createOrganization field.
|
||||
@@ -1104,7 +1118,6 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
||||
ctx,
|
||||
probo.CreatePeopleRequest{
|
||||
OrganizationID: organization.ID,
|
||||
UserID: &UserFromContext(ctx).ID,
|
||||
FullName: UserFromContext(ctx).FullName,
|
||||
PrimaryEmailAddress: UserFromContext(ctx).EmailAddress,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
@@ -1379,48 +1392,49 @@ func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.Confirm
|
||||
|
||||
// InviteUser is the resolver for the inviteUser field.
|
||||
func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
authzSvc := r.AuthzService(ctx, input.OrganizationID.TenantID())
|
||||
invitation, err := authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
panic(fmt.Errorf("failed to invite user to organization: %w", err))
|
||||
}
|
||||
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == input.OrganizationID {
|
||||
invitation, err := r.authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.CreatePeople {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
_, err := prb.Peoples.Create(ctx, probo.CreatePeopleRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
FullName: input.FullName,
|
||||
PrimaryEmailAddress: input.Email,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
Kind: coredata.PeopleKindEmployee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create people record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &types.InviteUserPayload{
|
||||
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
|
||||
}, nil
|
||||
if input.CreatePeople {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
_, err := prb.Peoples.Create(ctx, probo.CreatePeopleRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
FullName: input.FullName,
|
||||
PrimaryEmailAddress: input.Email,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
Kind: coredata.PeopleKindEmployee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create people record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("organization not found")
|
||||
return &types.InviteUserPayload{
|
||||
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AcceptInvitation is the resolver for the acceptInvitation field.
|
||||
func (r *mutationResolver) AcceptInvitation(ctx context.Context, input types.AcceptInvitationInput) (*types.AcceptInvitationPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
invitation, err := r.authzSvc.AcceptInvitationByID(ctx, input.InvitationID, user.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to accept invitation: %w", err))
|
||||
}
|
||||
|
||||
return &types.AcceptInvitationPayload{Invitation: types.NewInvitation(invitation)}, nil
|
||||
}
|
||||
|
||||
// DeleteInvitation is the resolver for the deleteInvitation field.
|
||||
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
|
||||
err := r.authzSvc.DeleteInvitation(ctx, input.InvitationID)
|
||||
authzSvc := r.AuthzService(ctx, input.InvitationID.TenantID())
|
||||
err := authzSvc.DeleteInvitation(ctx, input.InvitationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
panic(fmt.Errorf("failed to delete invitation: %w", err))
|
||||
}
|
||||
|
||||
return &types.DeleteInvitationPayload{
|
||||
@@ -1430,25 +1444,13 @@ func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.Del
|
||||
|
||||
// RemoveMember is the resolver for the removeMember field.
|
||||
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
authzSvc := r.AuthzService(ctx, input.OrganizationID.TenantID())
|
||||
err := authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == input.OrganizationID {
|
||||
err := r.authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.RemoveMemberPayload{Success: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("organization not found")
|
||||
return &types.RemoveMemberPayload{DeletedMemberID: input.MemberID}, nil
|
||||
}
|
||||
|
||||
// CreatePeople is the resolver for the createPeople field.
|
||||
@@ -3611,16 +3613,17 @@ func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organ
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := r.authzSvc.GetAllOrganizationMemberships(ctx, obj.ID, cursor)
|
||||
authzSvc := r.AuthzService(ctx, obj.ID.TenantID())
|
||||
page, err := authzSvc.GetMembershipsByOrganizationID(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list memberships: %w", err))
|
||||
}
|
||||
|
||||
return types.NewMembershipConnection(page), nil
|
||||
return types.NewMembershipConnection(page, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// Invitations is the resolver for the invitations field.
|
||||
func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder) (*types.InvitationConnection, error) {
|
||||
func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder, filter *types.InvitationFilter) (*types.InvitationConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: coredata.InvitationOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
@@ -3634,12 +3637,13 @@ func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organ
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := r.authzSvc.GetAllOrganizationInvitations(ctx, obj.ID, cursor)
|
||||
authzSvc := r.AuthzService(ctx, obj.ID.TenantID())
|
||||
page, err := authzSvc.GetInvitationsByOrganizationID(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list invitations: %w", err))
|
||||
}
|
||||
|
||||
return types.NewInvitationConnection(page), nil
|
||||
return types.NewInvitationConnection(page, r, obj.ID, filter), nil
|
||||
}
|
||||
|
||||
// Connectors is the resolver for the connectors field.
|
||||
@@ -4943,41 +4947,19 @@ func (r *trustCenterReferenceConnectionResolver) TotalCount(ctx context.Context,
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// People is the resolver for the people field.
|
||||
func (r *userResolver) People(ctx context.Context, obj *types.User, organizationID gid.GID) (*types.People, error) {
|
||||
prb := r.ProboService(ctx, organizationID.TenantID())
|
||||
|
||||
people, err := prb.Peoples.GetByUserID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
var errPeopleNotFound *coredata.ErrPeopleNotFound
|
||||
if errors.As(err, &errPeopleNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
panic(fmt.Errorf("failed to get people: %w", err))
|
||||
}
|
||||
|
||||
return types.NewPeople(people), nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *userConnectionResolver) TotalCount(ctx context.Context, obj *types.UserConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
switch obj.Resolver.(type) {
|
||||
case *organizationResolver:
|
||||
authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
|
||||
count, err := authzSvc.CountOrganizationUsers(ctx, obj.ParentID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to count organization users: %w", err))
|
||||
}
|
||||
return count, nil
|
||||
default:
|
||||
panic(fmt.Errorf("unknown resolver type for user connection"))
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
@@ -5353,6 +5335,35 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
|
||||
return types.NewOrganizationConnection(page), nil
|
||||
}
|
||||
|
||||
// Invitations is the resolver for the invitations field.
|
||||
func (r *viewerResolver) Invitations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder, filter *types.InvitationFilter) (*types.InvitationConnection, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: coredata.InvitationOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
}
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
invitationFilter := coredata.NewInvitationFilter(nil)
|
||||
if filter != nil {
|
||||
invitationFilter = coredata.NewInvitationFilter(filter.OnlyPending)
|
||||
}
|
||||
|
||||
invitations, err := r.authzSvc.GetUserInvitations(ctx, user.EmailAddress, cursor, invitationFilter)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list invitations for user: %w", err))
|
||||
}
|
||||
|
||||
return types.NewInvitationConnection(invitations, r, gid.GID{}, filter), nil
|
||||
}
|
||||
|
||||
// Asset returns schema.AssetResolver implementation.
|
||||
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
||||
|
||||
@@ -5432,6 +5443,9 @@ func (r *Resolver) FrameworkConnection() schema.FrameworkConnectionResolver {
|
||||
return &frameworkConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Invitation returns schema.InvitationResolver implementation.
|
||||
func (r *Resolver) Invitation() schema.InvitationResolver { return &invitationResolver{r} }
|
||||
|
||||
// InvitationConnection returns schema.InvitationConnectionResolver implementation.
|
||||
func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver {
|
||||
return &invitationConnectionResolver{r}
|
||||
@@ -5541,9 +5555,6 @@ func (r *Resolver) TrustCenterReferenceConnection() schema.TrustCenterReferenceC
|
||||
return &trustCenterReferenceConnectionResolver{r}
|
||||
}
|
||||
|
||||
// User returns schema.UserResolver implementation.
|
||||
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
|
||||
|
||||
// UserConnection returns schema.UserConnectionResolver implementation.
|
||||
func (r *Resolver) UserConnection() schema.UserConnectionResolver { return &userConnectionResolver{r} }
|
||||
|
||||
@@ -5603,6 +5614,7 @@ type evidenceConnectionResolver struct{ *Resolver }
|
||||
type fileResolver struct{ *Resolver }
|
||||
type frameworkResolver struct{ *Resolver }
|
||||
type frameworkConnectionResolver struct{ *Resolver }
|
||||
type invitationResolver struct{ *Resolver }
|
||||
type invitationConnectionResolver struct{ *Resolver }
|
||||
type measureResolver struct{ *Resolver }
|
||||
type measureConnectionResolver struct{ *Resolver }
|
||||
@@ -5630,7 +5642,6 @@ type trustCenterDocumentAccessResolver struct{ *Resolver }
|
||||
type trustCenterDocumentAccessConnectionResolver struct{ *Resolver }
|
||||
type trustCenterReferenceResolver struct{ *Resolver }
|
||||
type trustCenterReferenceConnectionResolver struct{ *Resolver }
|
||||
type userResolver struct{ *Resolver }
|
||||
type userConnectionResolver struct{ *Resolver }
|
||||
type vendorResolver struct{ *Resolver }
|
||||
type vendorBusinessAssociateAgreementResolver struct{ *Resolver }
|
||||
|
||||
Reference in New Issue
Block a user