Refactor invitation system
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user