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

@@ -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"