@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user