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

@@ -24,17 +24,14 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
type (
Invitation struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Email mail.Addr `db:"email"`
FullName string `db:"full_name"`
Role MembershipRole `db:"role"`
OrganizationID gid.GID `fb:"organization_id"`
UserID gid.GID `db:"user_id"`
Status InvitationStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
AcceptedAt *time.Time `db:"accepted_at"`
@@ -46,22 +43,8 @@ type (
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
return page.NewCursorKey(i.ID, i.FullName)
case InvitationOrderFieldEmail:
return page.NewCursorKey(i.ID, i.Email)
case InvitationOrderFieldRole:
return page.NewCursorKey(i.ID, i.Role)
case InvitationOrderFieldCreatedAt:
return page.NewCursorKey(i.ID, i.CreatedAt)
case InvitationOrderFieldExpiresAt:
return page.NewCursorKey(i.ID, i.ExpiresAt)
case InvitationOrderFieldAcceptedAt:
acceptedAt := time.Time{}
if i.AcceptedAt != nil {
acceptedAt = *i.AcceptedAt
}
return page.NewCursorKey(i.ID, acceptedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
@@ -72,21 +55,17 @@ func (i *Invitation) Insert(ctx context.Context, conn pg.Conn, scope Scoper) err
INSERT INTO
iam_invitations (
tenant_id,
organization_id,
user_id,
id,
organization_id,
email,
full_name,
role,
expires_at,
created_at
)
VALUES (
@tenant_id,
@organization_id,
@user_id,
@id,
@organization_id,
@email,
@full_name,
@role,
@expires_at,
@created_at
);
@@ -94,11 +73,9 @@ VALUES (
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": i.ID,
"organization_id": i.OrganizationID,
"email": i.Email,
"full_name": i.FullName,
"role": i.Role,
"id": i.ID,
"user_id": i.UserID,
"expires_at": i.ExpiresAt,
"created_at": i.CreatedAt,
}
@@ -120,10 +97,8 @@ func (i *Invitation) LoadByID(
query := `
SELECT
id,
organization_id,
email,
full_name,
role,
organization_id,
user_id,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
@@ -164,49 +139,12 @@ WHERE
return nil
}
func (i *Invitations) ExpireByEmailAndOrganization(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
organizationID gid.GID,
filter *InvitationFilter,
) error {
q := `
UPDATE
iam_invitations
SET
expires_at = NOW()
WHERE
email = @email
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot expire invitations: %w", err)
}
return nil
}
// AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation.
// It is intentionally lightweight and does not populate the Invitation struct.
func (i *Invitation) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `
SELECT
email
, organization_id
email, organization_id
FROM
iam_invitations
WHERE
@@ -288,11 +226,11 @@ WHERE
return nil
}
func (i *Invitations) LoadByIdentityID(
func (i *Invitations) LoadByUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
userID gid.GID,
cursor *page.Cursor[InvitationOrderField],
filter *InvitationFilter,
) error {
@@ -300,9 +238,7 @@ func (i *Invitations) LoadByIdentityID(
SELECT
id,
organization_id,
email,
full_name,
role,
user_id,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
@@ -314,60 +250,7 @@ SELECT
FROM
iam_invitations
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 {
return fmt.Errorf("cannot query invitations: %w", err)
}
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
if err != nil {
return fmt.Errorf("cannot collect invitations: %w", err)
}
*i = invitations
return nil
}
func (i *Invitations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
cursor *page.Cursor[InvitationOrderField],
filter *InvitationFilter,
) error {
query := `
SELECT
id,
organization_id,
email,
full_name,
role,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
ELSE 'PENDING'
END as status,
expires_at,
accepted_at,
created_at
FROM
iam_invitations
WHERE
organization_id = @organization_id
user_id = @user_id
AND %s
AND %s
AND %s
@@ -376,7 +259,7 @@ WHERE
query = fmt.Sprintf(query, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"user_id": userID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
@@ -396,73 +279,36 @@ WHERE
return nil
}
func (i *Invitations) CountByOrganizationID(
func (i *Invitations) ExpireByUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
userID gid.GID,
filter *InvitationFilter,
) (int, error) {
) error {
q := `
SELECT
COUNT(*)
FROM
iam_invitations
WHERE
organization_id = @organization_id AND %s AND %s
`
UPDATE
iam_invitations
SET
expires_at = NOW()
WHERE
user_id = @user_id
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"user_id": userID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.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)
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot expire 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 mail.Addr,
filter *InvitationFilter,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_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)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count invitations: %w", err)
}
return count, nil
return nil
}

View File

@@ -21,28 +21,13 @@ type InvitationOrderField string
// InvitationOrderField constants
const (
InvitationOrderFieldFullName InvitationOrderField = "FULL_NAME"
InvitationOrderFieldEmail InvitationOrderField = "EMAIL"
InvitationOrderFieldRole InvitationOrderField = "ROLE"
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
InvitationOrderFieldExpiresAt InvitationOrderField = "EXPIRES_AT"
InvitationOrderFieldAcceptedAt InvitationOrderField = "ACCEPTED_AT"
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
)
func (p InvitationOrderField) Column() string {
switch p {
case InvitationOrderFieldFullName:
return "full_name"
case InvitationOrderFieldEmail:
return "email"
case InvitationOrderFieldRole:
return "role"
case InvitationOrderFieldCreatedAt:
return "created_at"
case InvitationOrderFieldExpiresAt:
return "expires_at"
case InvitationOrderFieldAcceptedAt:
return "accepted_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
@@ -50,7 +35,7 @@ func (p InvitationOrderField) Column() string {
func (e InvitationOrderField) IsValid() bool {
switch e {
case InvitationOrderFieldFullName, InvitationOrderFieldEmail, InvitationOrderFieldRole, InvitationOrderFieldCreatedAt, InvitationOrderFieldExpiresAt, InvitationOrderFieldAcceptedAt:
case InvitationOrderFieldCreatedAt:
return true
}
return false

View File

@@ -49,3 +49,152 @@ ALTER TABLE
iam_scim_events
ALTER COLUMN
user_name DROP DEFAULT;
-- Convert invitations to identities / profiles / memberships
-- Create missing identities (one row per email to avoid "cannot affect row a second time")
INSERT INTO
identities (
id,
created_at,
updated_at,
email_address,
email_address_verified,
full_name
)
SELECT
generate_gid('\x0000000000000000' :: bytea, 11),
NOW(),
NOW(),
i.email,
FALSE,
i.full_name
FROM
(
SELECT
DISTINCT ON (email) email,
full_name
FROM
iam_invitations
WHERE
accepted_at IS NULL
ORDER BY
email
) i ON CONFLICT (email_address) DO
UPDATE
SET
full_name = EXCLUDED.full_name;
-- Create missing profiles
WITH invitation_identities AS (
SELECT
i.id AS identity_id,
inv.tenant_id AS tenant_id,
inv.organization_id AS organization_id,
inv.full_name AS full_name
FROM
iam_invitations inv
INNER JOIN identities i ON i.email_address = inv.email
WHERE
inv.accepted_at IS NULL
)
INSERT INTO
iam_membership_profiles (
id,
tenant_id,
identity_id,
organization_id,
full_name,
kind,
additional_email_addresses,
source,
state,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(ii.tenant_id), 51),
ii.tenant_id,
ii.identity_id,
ii.organization_id,
ii.full_name,
'EMPLOYEE',
'{}' :: CITEXT [],
'MANUAL',
'INACTIVE',
NOW(),
NOW()
FROM
invitation_identities ii ON CONFLICT DO NOTHING;
-- Create missing memberships
WITH invitation_identities AS (
SELECT
i.id AS identity_id,
inv.tenant_id AS tenant_id,
inv.organization_id AS organization_id,
inv.role AS role
FROM
iam_invitations inv
INNER JOIN identities i ON i.email_address = inv.email
WHERE
inv.accepted_at IS NULL
)
INSERT INTO
iam_memberships (
id,
tenant_id,
identity_id,
organization_id,
role,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(ii.tenant_id), 39),
ii.tenant_id,
ii.identity_id,
ii.organization_id,
ii.role,
NOW(),
NOW()
FROM
invitation_identities ii ON CONFLICT DO NOTHING;
ALTER TABLE
iam_invitations
ADD
COLUMN user_id TEXT REFERENCES iam_membership_profiles(id);
WITH profile_identities AS (
SELECT
p.id AS profile_id,
i.email_address,
p.organization_id
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
)
UPDATE
iam_invitations i
SET
user_id = pi.profile_id
FROM
profile_identities pi
WHERE
pi.organization_id = i.organization_id
AND pi.email_address = i.email;
ALTER TABLE
iam_invitations
ALTER COLUMN
user_id
SET
NOT NULL,
ALTER COLUMN
email DROP NOT NULL,
ALTER COLUMN
role TYPE TEXT USING role :: text,
ALTER COLUMN
role DROP NOT NULL,
ALTER COLUMN
full_name DROP NOT NULL;

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
},
)

View File

@@ -53,9 +53,9 @@ type Mutation {
signIn(input: SignInInput!): SignInPayload @session(required: OPTIONAL)
signUp(input: SignUpInput!): SignUpPayload @session(required: NONE)
signOut: SignOutPayload @session(required: PRESENT)
signUpFromInvitation(
input: SignUpFromInvitationInput!
): SignUpFromInvitationPayload @session(required: NONE)
activateAccount(
input: ActivateAccountInput!
): ActivateAccountPayload @session(required: NONE)
forgotPassword(input: ForgotPasswordInput!): ForgotPasswordPayload
@session(required: NONE)
resetPassword(input: ResetPasswordInput!): ResetPasswordPayload
@@ -98,16 +98,11 @@ type Mutation {
@session(required: PRESENT)
inviteUser(input: InviteUserInput!): InviteUserPayload
@session(required: PRESENT)
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload
@session(required: PRESENT)
updateUser(input: UpdateUserInput!): UpdateUserPayload!
updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload
@session(required: PRESENT)
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload
@session(required: PRESENT)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload @session(required: PRESENT)
@@ -148,14 +143,6 @@ type Identity implements Node {
orderBy: ProfileOrder
): ProfileConnection @goField(forceResolver: true)
pendingInvitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: InvitationOrder
): InvitationConnection @goField(forceResolver: true)
sessions(
first: Int
after: CursorKey
@@ -197,6 +184,13 @@ type Profile implements Node {
identity: Identity @goField(forceResolver: true)
organization: Organization @goField(forceResolver: true)
membership: Membership @goField(forceResolver: true)
pendingInvitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: InvitationOrder
): InvitationConnection @goField(forceResolver: true)
permission(action: String!): Boolean!
@goField(forceResolver: true)
@@ -249,15 +243,6 @@ type Organization implements Node {
orderBy: ProfileOrder
): ProfileConnection @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
status: InvitationStatus
orderBy: InvitationOrder
): InvitationConnection @goField(forceResolver: true)
samlConfigurations(
first: Int
after: CursorKey
@@ -299,13 +284,12 @@ type Membership implements Node {
type Invitation implements Node {
id: ID!
email: EmailAddr!
fullName: String!
role: MembershipRole!
expiresAt: Datetime!
acceptedAt: Datetime
createdAt: Datetime!
status: InvitationStatus!
user: Profile @goField(forceResolver: true)
organization: Organization @goField(forceResolver: true)
permission(action: String!): Boolean!
@@ -483,26 +467,10 @@ enum ReauthenticationReason {
enum InvitationOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.InvitationOrderField") {
FULL_NAME
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldFullName"
)
EMAIL
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldEmail")
ROLE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldRole")
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldCreatedAt"
)
EXPIRES_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldExpiresAt"
)
ACCEPTED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.InvitationOrderFieldAcceptedAt"
)
}
input InvitationOrder
@@ -658,10 +626,9 @@ input SignUpInput {
fullName: String!
}
input SignUpFromInvitationInput {
input ActivateAccountInput {
token: String!
password: String!
fullName: String!
}
input ForgotPasswordInput {
@@ -823,8 +790,8 @@ type SignOutPayload {
success: Boolean!
}
type SignUpFromInvitationPayload {
identity: Identity
type ActivateAccountPayload {
profile: Profile
}
type ForgotPasswordPayload {

File diff suppressed because it is too large Load Diff

View File

@@ -65,13 +65,13 @@ func NewInvitationEdge(invitation *coredata.Invitation, orderField coredata.Invi
func NewInvitation(invitation *coredata.Invitation) *Invitation {
return &Invitation{
ID: invitation.ID,
Email: invitation.Email,
Role: invitation.Role,
FullName: invitation.FullName,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
Status: invitation.Status,
User: &Profile{
ID: invitation.UserID,
},
Organization: &Organization{
ID: invitation.OrganizationID,
},

View File

@@ -34,6 +34,15 @@ type AcceptInvitationPayload struct {
Invitation *Invitation `json:"invitation"`
}
type ActivateAccountInput struct {
Token string `json:"token"`
Password string `json:"password"`
}
type ActivateAccountPayload struct {
Profile *Profile `json:"profile,omitempty"`
}
type AssumeOrganizationSessionInput struct {
OrganizationID gid.GID `json:"organizationId"`
Continue string `json:"continue"`
@@ -186,18 +195,16 @@ type ForgotPasswordPayload struct {
}
type Identity struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
EmailVerified bool `json:"emailVerified"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Profiles *ProfileConnection `json:"profiles,omitempty"`
PendingInvitations *InvitationConnection `json:"pendingInvitations,omitempty"`
Sessions *SessionConnection `json:"sessions,omitempty"`
PersonalAPIKeys *PersonalAPIKeyConnection `json:"personalAPIKeys,omitempty"`
SsoLoginURL *string `json:"ssoLoginURL,omitempty"`
Permission bool `json:"permission"`
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
EmailVerified bool `json:"emailVerified"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Profiles *ProfileConnection `json:"profiles,omitempty"`
Sessions *SessionConnection `json:"sessions,omitempty"`
PersonalAPIKeys *PersonalAPIKeyConnection `json:"personalAPIKeys,omitempty"`
Permission bool `json:"permission"`
}
func (Identity) IsNode() {}
@@ -205,13 +212,11 @@ func (this Identity) GetID() gid.GID { return this.ID }
type Invitation struct {
ID gid.GID `json:"id"`
Email mail.Addr `json:"email"`
FullName string `json:"fullName"`
Role coredata.MembershipRole `json:"role"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Status coredata.InvitationStatus `json:"status"`
User *Profile `json:"user,omitempty"`
Organization *Organization `json:"organization,omitempty"`
Permission bool `json:"permission"`
}
@@ -259,7 +264,6 @@ type Organization struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Profiles *ProfileConnection `json:"profiles,omitempty"`
Invitations *InvitationConnection `json:"invitations,omitempty"`
SamlConfigurations *SAMLConfigurationConnection `json:"samlConfigurations,omitempty"`
ScimConfiguration *SCIMConfiguration `json:"scimConfiguration,omitempty"`
Viewer *Profile `json:"viewer,omitempty"`
@@ -323,6 +327,7 @@ type Profile struct {
Identity *Identity `json:"identity,omitempty"`
Organization *Organization `json:"organization,omitempty"`
Membership *Membership `json:"membership,omitempty"`
PendingInvitations *InvitationConnection `json:"pendingInvitations,omitempty"`
Permission bool `json:"permission"`
}
@@ -520,16 +525,6 @@ type SignOutPayload struct {
Success bool `json:"success"`
}
type SignUpFromInvitationInput struct {
Token string `json:"token"`
Password string `json:"password"`
FullName string `json:"fullName"`
}
type SignUpFromInvitationPayload struct {
Identity *Identity `json:"identity,omitempty"`
}
type SignUpInput struct {
Email mail.Addr `json:"email"`
Password string `json:"password"`

View File

@@ -69,35 +69,6 @@ func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, fi
return types.NewProfileConnection(page, r, obj.ID), nil
}
// PendingInvitations is the resolver for the pendingInvitations field.
func (r *identityResolver) PendingInvitations(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrderBy) (*types.InvitationConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionInvitationList); err != nil {
return nil, err
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.InvitationConnection{
Resolver: r,
ParentID: obj.ID,
}, nil
}
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListPendingInvitations(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list pending invitations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewInvitationConnection(page, r, obj.ID, nil), nil
}
// Sessions is the resolver for the sessions field.
func (r *identityResolver) Sessions(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SessionOrder) (*types.SessionConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionSessionList); err != nil {
@@ -211,6 +182,11 @@ func (r *identityResolver) Permission(ctx context.Context, obj *types.Identity,
return r.Resolver.Permission(ctx, obj, action)
}
// User is the resolver for the user field.
func (r *invitationResolver) User(ctx context.Context, obj *types.Invitation) (*types.Profile, error) {
panic(fmt.Errorf("not implemented: User - user"))
}
// Organization is the resolver for the organization field.
func (r *invitationResolver) Organization(ctx context.Context, obj *types.Invitation) (*types.Organization, error) {
if err := r.authorize(ctx, obj.Organization.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil {
@@ -239,34 +215,7 @@ func (r *invitationResolver) Permission(ctx context.Context, obj *types.Invitati
// TotalCount is the resolver for the totalCount field.
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (*int, error) {
switch obj.Resolver.(type) {
case *organizationResolver:
if err := r.authorize(ctx, obj.ParentID, iam.ActionInvitationList); err != nil {
return nil, err
}
count, err := r.iam.OrganizationService.CountInvitations(ctx, obj.ParentID, obj.Filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count invitations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &count, nil
case *identityResolver:
if err := r.authorize(ctx, obj.ParentID, iam.ActionInvitationList); err != nil {
return nil, err
}
count, err := r.iam.AccountService.CountPendingInvitations(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count invitations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver", log.Any("resolver", obj.Resolver))
return nil, gqlutils.Internal(ctx)
panic(fmt.Errorf("not implemented: TotalCount - totalCount"))
}
// LastSession is the resolver for the lastSession field.
@@ -426,14 +375,13 @@ func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload,
return &types.SignOutPayload{Success: true}, nil
}
// SignUpFromInvitation is the resolver for the signUpFromInvitation field.
func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types.SignUpFromInvitationInput) (*types.SignUpFromInvitationPayload, error) {
identity, session, err := r.iam.AuthService.CreateIdentityFromInvitation(
// ActivateAccount is the resolver for the signUpFromInvitation field.
func (r *mutationResolver) ActivateAccount(ctx context.Context, input types.ActivateAccountInput) (*types.ActivateAccountPayload, error) {
user, session, err := r.iam.AuthService.ActivateAccount(
ctx,
&iam.CreateIdentityFromInvitationRequest{
InvitationToken: input.Token,
Password: input.Password,
FullName: input.FullName,
},
)
if err != nil {
@@ -442,7 +390,6 @@ func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types
errInvitationNotFound *iam.ErrInvitationNotFound
errInvitationAlreadyAccepted *iam.ErrInvitationAlreadyAccepted
errInvitationExpired *iam.ErrInvitationExpired
errIdentityAlreadyExists *iam.ErrIdentityAlreadyExists
isInvalidErr = errors.As(err, &errInvalidToken) ||
errors.As(err, &errInvitationNotFound) ||
@@ -454,25 +401,15 @@ func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types
return nil, gqlutils.Invalid(ctx, err)
}
if errors.As(err, &errIdentityAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot create identity from invitation", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot activate account from invitation", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
w := gqlutils.HTTPResponseWriterFromContext(ctx)
r.sessionCookie.Set(w, session)
return &types.SignUpFromInvitationPayload{
Identity: &types.Identity{
ID: identity.ID,
Email: identity.EmailAddress,
EmailVerified: identity.EmailAddressVerified,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
},
return &types.ActivateAccountPayload{
Profile: types.NewProfile(user),
}, nil
}
@@ -942,32 +879,6 @@ func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUse
}, nil
}
// DeleteInvitation is the resolver for the deleteInvitation field.
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, iam.ActionInvitationDelete); err != nil {
return nil, err
}
err := r.iam.OrganizationService.DeleteInvitation(ctx, input.OrganizationID, input.InvitationID)
if err != nil {
var errInvitationNotFound *iam.ErrInvitationNotFound
var errInvitationNotDeleted *iam.ErrInvitationNotDeleted
if errors.As(err, &errInvitationNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
if errors.As(err, &errInvitationNotDeleted) {
return nil, gqlutils.Invalid(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot delete invitation", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil
}
// UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUserInput) (*types.UpdateUserPayload, error) {
if err := r.authorize(ctx, input.ID, iam.ActionMembershipProfileUpdate); err != nil {
@@ -1045,26 +956,6 @@ func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUse
return &types.RemoveUserPayload{DeletedProfileID: input.ProfileID}, nil
}
// AcceptInvitation is the resolver for the acceptInvitation field.
func (r *mutationResolver) AcceptInvitation(ctx context.Context, input types.AcceptInvitationInput) (*types.AcceptInvitationPayload, error) {
if err := r.authorize(ctx, input.InvitationID, iam.ActionInvitationAccept); err != nil {
return nil, err
}
identity := authn.IdentityFromContext(ctx)
invitation, membership, err := r.iam.AccountService.AcceptInvitation(ctx, identity.ID, input.InvitationID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot accept invitation", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AcceptInvitationPayload{
Membership: types.NewMembership(membership),
Invitation: types.NewInvitation(invitation),
}, nil
}
// CreateSAMLConfiguration is the resolver for the createSAMLConfiguration field.
func (r *mutationResolver) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, iam.ActionSAMLConfigurationCreate); err != nil {
@@ -1311,47 +1202,6 @@ func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organiza
return types.NewProfileConnection(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, status *coredata.InvitationStatus, orderBy *types.InvitationOrderBy) (*types.InvitationConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionInvitationList); err != nil {
return nil, err
}
filters := coredata.NewInvitationFilter(nil)
if status != nil {
filters = coredata.NewInvitationFilter([]coredata.InvitationStatus{*status})
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.InvitationConnection{
Resolver: r,
ParentID: obj.ID,
Filters: filters,
}, nil
}
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 := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.OrganizationService.ListInvitations(ctx, obj.ID, cursor, filters)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list invitations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewInvitationConnection(page, r, obj.ID, filters), nil
}
// SamlConfigurations is the resolver for the samlConfigurations field.
func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SAMLConfigurationConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionSAMLConfigurationList); err != nil {
@@ -1536,6 +1386,35 @@ func (r *profileResolver) Membership(ctx context.Context, obj *types.Profile) (*
return types.NewMembership(membership), nil
}
// PendingInvitations is the resolver for the pendingInvitations field.
func (r *profileResolver) PendingInvitations(ctx context.Context, obj *types.Profile, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrderBy) (*types.InvitationConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionInvitationList); err != nil {
return nil, err
}
if gqlutils.OnlyTotalCountSelected(ctx) {
return &types.InvitationConnection{
Resolver: r,
ParentID: obj.ID,
}, nil
}
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := cursor.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.iam.AccountService.ListPendingInvitations(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list pending invitations", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewInvitationConnection(page, r, obj.ID, nil), nil
}
// Permission is the resolver for the permission field.
func (r *profileResolver) Permission(ctx context.Context, obj *types.Profile, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)

View File

@@ -7,12 +7,10 @@ package mcp_v1
import (
"context"
"fmt"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"