Reimplement invitations

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-17 00:26:22 +04:00
parent a42fc4fa61
commit 47aba96b69
43 changed files with 1651 additions and 4550 deletions

View File

@@ -181,176 +181,24 @@ func (s AccountService) VerifyEmail(ctx context.Context, token string) error {
)
}
func (s *AccountService) AcceptInvitation(
ctx context.Context,
identityID gid.GID,
invitationID gid.GID,
) (*coredata.Invitation, *coredata.Membership, error) {
var (
now = time.Now()
profile = &coredata.MembershipProfile{}
membership = &coredata.Membership{}
invitation = &coredata.Invitation{}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
identity := coredata.Identity{}
if err := identity.LoadByID(ctx, tx, identityID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewIdentityNotFoundError(identityID)
}
return fmt.Errorf("cannot load identity: %w", err)
}
if err := invitation.LoadByID(ctx, tx, coredata.NewNoScope(), invitationID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.Email != identity.EmailAddress {
return NewInvitationNotFoundError(invitationID)
}
if invitation.AcceptedAt != nil {
return NewInvitationAlreadyAcceptedError(invitationID)
}
if invitation.ExpiresAt.Before(now) {
return NewInvitationExpiredError(invitationID)
}
tenantID := invitation.OrganizationID.TenantID()
scope := coredata.NewScope(invitation.OrganizationID.TenantID())
existingProfile := &coredata.MembershipProfile{}
if err := existingProfile.LoadByIdentityIDAndOrganizationID(
ctx,
tx,
scope,
identityID,
invitation.OrganizationID,
); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load existing profile: %w", err)
}
profile = &coredata.MembershipProfile{
ID: gid.New(tenantID, coredata.MembershipProfileEntityType),
IdentityID: identity.ID,
OrganizationID: invitation.OrganizationID,
Source: coredata.ProfileSourceManual,
State: coredata.ProfileStateActive,
FullName: identity.FullName,
CreatedAt: now,
UpdatedAt: now,
}
if err := profile.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert profile: %w", err)
}
} else {
if existingProfile.State == coredata.ProfileStateInactive {
existingProfile.State = coredata.ProfileStateActive
if err := existingProfile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot reactivate profile: %w", err)
}
}
profile = existingProfile
}
existingMembership := &coredata.Membership{}
if err := existingMembership.LoadByIdentityIDAndOrganizationID(
ctx,
tx,
scope,
identityID,
invitation.OrganizationID,
); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load existing membership: %w", err)
}
membership = &coredata.Membership{
ID: gid.New(tenantID, coredata.MembershipEntityType),
IdentityID: identityID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
} else {
existingMembership.Role = invitation.Role
existingMembership.UpdatedAt = now
if err := existingMembership.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot assign membership role: %w", err)
}
membership = existingMembership
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot update invitation: %w", err)
}
// Expire other pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(invitation.OrganizationID),
invitation.Email,
invitation.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations by email: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return invitation, membership, nil
}
func (s *AccountService) ListPendingInvitations(
ctx context.Context,
identityID gid.GID,
userID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
var (
scope = coredata.NewScopeFromObjectID(userID)
invitations coredata.Invitations
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.Identity{}
err := identity.LoadByID(ctx, conn, identityID)
profile := coredata.MembershipProfile{}
err := profile.LoadByID(ctx, conn, scope, userID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewIdentityNotFoundError(identityID)
return NewIdentityNotFoundError(userID)
}
return fmt.Errorf("cannot load identity: %w", err)
@@ -358,7 +206,7 @@ func (s *AccountService) ListPendingInvitations(
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err = invitations.LoadByIdentityID(ctx, conn, coredata.NewNoScope(), identity.EmailAddress, cursor, onlyPending)
err = invitations.LoadByUserID(ctx, conn, scope, userID, cursor, onlyPending)
if err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
@@ -374,39 +222,6 @@ func (s *AccountService) ListPendingInvitations(
return page.NewPage(invitations, cursor), nil
}
func (s *AccountService) CountPendingInvitations(
ctx context.Context,
identityID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.Identity{}
err := identity.LoadByID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
invitations := coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
count, err = invitations.CountByEmail(ctx, conn, identity.EmailAddress, onlyPending)
if err != nil {
return fmt.Errorf("cannot count pending invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, req *ChangePasswordRequest) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)

View File

@@ -48,7 +48,6 @@ type (
CreateIdentityFromInvitationRequest struct {
InvitationToken string
Password string
FullName string
}
LoadOrCreateIdentityRequest struct {
@@ -93,7 +92,6 @@ func (req CreateIdentityFromInvitationRequest) Validate() error {
v := validator.New()
v.Check(req.InvitationToken, "invitationToken", validator.NotEmpty())
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
@@ -134,10 +132,10 @@ func (req CreateIdentityWithPasswordRequest) Validate() error {
return v.Error()
}
func (s *AuthService) CreateIdentityFromInvitation(
func (s *AuthService) ActivateAccount(
ctx context.Context,
req *CreateIdentityFromInvitationRequest,
) (*coredata.Identity, *coredata.Session, error) {
) (*coredata.MembershipProfile, *coredata.Session, error) {
if err := req.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid request: %w", err)
}
@@ -150,8 +148,8 @@ func (s *AuthService) CreateIdentityFromInvitation(
var (
scope = coredata.NewScopeFromObjectID(payload.Data.InvitationID)
invitation = &coredata.Invitation{}
identity = &coredata.Identity{}
session = &coredata.Session{}
profile *coredata.MembershipProfile
session *coredata.Session
now = time.Now()
)
@@ -180,23 +178,54 @@ func (s *AuthService) CreateIdentityFromInvitation(
return NewInvitationExpiredError(payload.Data.InvitationID)
}
identity = &coredata.Identity{
ID: gid.New(gid.NilTenant, coredata.IdentityEntityType),
EmailAddress: invitation.Email,
FullName: invitation.FullName,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
CreatedAt: now,
UpdatedAt: now,
profile = &coredata.MembershipProfile{}
if err := profile.LoadByID(ctx, tx, scope, invitation.UserID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
err = identity.Insert(ctx, tx)
if profile.State == coredata.ProfileStateInactive {
profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
}
identity := &coredata.Identity{}
if err := identity.LoadByID(ctx, tx, profile.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
identity.HashedPassword = hashedPassword
identity.EmailAddressVerified = true
identity.UpdatedAt = now
err = identity.Update(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewIdentityAlreadyExistsError(invitation.Email)
return fmt.Errorf("cannot update identity: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(payload.Data.InvitationID)
}
return fmt.Errorf("cannot insert identity: %w", err)
return fmt.Errorf("cannot update invitation: %w", err)
}
// Expire other pending invitations for user
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByUserID(
ctx,
tx,
coredata.NewScopeFromObjectID(invitation.OrganizationID),
invitation.UserID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations: %w", err)
}
session = coredata.NewRootSession(identity.ID, coredata.AuthMethodPassword, s.sessionDuration)
@@ -213,7 +242,7 @@ func (s *AuthService) CreateIdentityFromInvitation(
return nil, nil, err
}
return identity, session, nil
return profile, session, nil
}
func (s AuthService) ResetPassword(

View File

@@ -333,99 +333,6 @@ func (s *OrganizationService) RemoveUser(
)
}
func (s *OrganizationService) DeleteInvitation(
ctx context.Context,
organizationID gid.GID,
invitationID gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(organizationID)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := coredata.Invitation{}
err := invitation.LoadByID(ctx, tx, scope, invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
switch invitation.Status {
case coredata.InvitationStatusAccepted:
return NewInvitationNotDeletedError(invitationID, invitation.Status.String())
case coredata.InvitationStatusPending, coredata.InvitationStatusExpired:
}
err = invitation.Delete(ctx, tx, scope, invitationID)
if err != nil {
return fmt.Errorf("cannot delete invitation: %w", err)
}
return nil
},
)
}
func (s *OrganizationService) ListInvitations(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var (
invitations coredata.Invitations
scope = coredata.NewScopeFromObjectID(organizationID)
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := invitations.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *OrganizationService) CountInvitations(
ctx context.Context,
organizationID gid.GID,
filter *coredata.InvitationFilter,
) (int, error) {
var (
count int
scope = coredata.NewScopeFromObjectID(organizationID)
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
invitations := coredata.Invitations{}
count, err = invitations.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *OrganizationService) InviteUser(
ctx context.Context,
req *CreateInvitationRequest,
@@ -436,6 +343,7 @@ func (s *OrganizationService) InviteUser(
invitation = &coredata.Invitation{
ID: gid.New(req.OrganizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: req.OrganizationID,
UserID: req.ProfileID,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
@@ -483,7 +391,7 @@ func (s *OrganizationService) InviteUser(
subject, textBody, htmlBody, err := emailPresenter.RenderInvitation(
ctx,
"/auth/signup-from-invitation",
"/auth/activate-account",
invitationToken,
organization.Name,
)

View File

@@ -352,20 +352,6 @@ func (s *Service) HandleAssertion(
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
email,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations by email: %w", err)
}
}
if profile.Source != coredata.ProfileSourceSCIM {

View File

@@ -235,22 +235,6 @@ func (s *Service) CreateUser(
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
emailAddr,
config.OrganizationID,
onlyPending,
)
if err != nil {
return fmt.Errorf("cannot expire pending invitations by email")
}
} else {
return fmt.Errorf("cannot load membership: %w", err)
}
@@ -476,33 +460,6 @@ func (s *Service) updateUser(
if shouldReactivate {
membership.Role = coredata.MembershipRoleEmployee
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.EmailAddress,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations by email: %w", err)
}
} else if shouldDeactivate {
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.EmailAddress,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations: %w", err)
}
}
}
@@ -552,24 +509,6 @@ func (s *Service) DeleteUser(
return fmt.Errorf("cannot delete membership: %w", err)
}
// Expire all pending invitations for email in organization
identity := &coredata.Identity{}
if err := identity.LoadByID(ctx, tx, membership.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.EmailAddress,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations: %w", err)
}
return nil
},
)