Add membership state

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 15:49:33 +01:00
parent 431f2fd557
commit 7030d06aed
19 changed files with 450 additions and 149 deletions

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<e01416ecd129c81e31f5da90bf797ff7>> * @generated SignedSource<<7759f335d6dd4b9ca6afbb601aa7794c>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -236,6 +236,13 @@ return {
"name": "source", "name": "source",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -396,12 +403,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "bdedd349fa3a3dec43d7cb6171d1cc6d", "cacheID": "bb44371bed2eb3032b52ea5bbb3109c1",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MemberListFragment_RefetchQuery", "name": "MemberListFragment_RefetchQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MemberListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MemberListFragment_16fISc\n id\n }\n}\n\nfragment MemberListFragment_16fISc on Organization {\n members(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" "text": "query MemberListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MemberListFragment_16fISc\n id\n }\n}\n\nfragment MemberListFragment_16fISc on Organization {\n members(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n state\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n"
} }
}; };
})(); })();

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<13e173a42e2ea29b7960539683cea087>> * @generated SignedSource<<81a5acfba89528014adf0731f16b9a90>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,6 +11,7 @@
import { ReaderFragment } from 'relay-runtime'; import { ReaderFragment } from 'relay-runtime';
export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type MembershipSource = "MANUAL" | "SAML" | "SCIM"; export type MembershipSource = "MANUAL" | "SAML" | "SCIM";
export type MembershipState = "ACTIVE" | "INACTIVE";
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type MemberListItemFragment$data = { export type MemberListItemFragment$data = {
readonly canDelete: boolean; readonly canDelete: boolean;
@@ -25,6 +26,7 @@ export type MemberListItemFragment$data = {
}; };
readonly role: MembershipRole; readonly role: MembershipRole;
readonly source: MembershipSource; readonly source: MembershipSource;
readonly state: MembershipState;
readonly " $fragmentType": "MemberListItemFragment"; readonly " $fragmentType": "MemberListItemFragment";
}; };
export type MemberListItemFragment$key = { export type MemberListItemFragment$key = {
@@ -59,6 +61,13 @@ const node: ReaderFragment = {
"name": "source", "name": "source",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{ {
"kind": "RequiredField", "kind": "RequiredField",
"field": { "field": {
@@ -141,6 +150,6 @@ const node: ReaderFragment = {
"abstractKey": null "abstractKey": null
}; };
(node as any).hash = "573655b969ddacac10dc8beb4585a4a1"; (node as any).hash = "ffa3431077faeeb5e5779975d68c82bb";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<bd98e1c1ff5f9393deaec2a4aec1649c>> * @generated SignedSource<<d7435d1c1ee31d5ddc186fd67fa9401f>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -345,6 +345,13 @@ return {
"name": "source", "name": "source",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -517,12 +524,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "b7d449bd737c6165219640affcc0260a", "cacheID": "9cc9ff1663db9c476c1cd67f097a4ab9",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MembersPageQuery", "name": "MembersPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MembersPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...InviteUserDialog_currentRoleFragment\n ...MemberListFragment_1jRT0c\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n ...InvitationListFragment_1jRT0c\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n canInviteUser: permission(action: \"iam:invitation:create\")\n }\n id\n }\n}\n\nfragment InvitationListFragment_1jRT0c on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment InviteUserDialog_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n\nfragment MemberListFragment_1jRT0c on Organization {\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" "text": "query MembersPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...InviteUserDialog_currentRoleFragment\n ...MemberListFragment_1jRT0c\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n ...InvitationListFragment_1jRT0c\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n canInviteUser: permission(action: \"iam:invitation:create\")\n }\n id\n }\n}\n\nfragment InvitationListFragment_1jRT0c on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment InviteUserDialog_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n\nfragment MemberListFragment_1jRT0c on Organization {\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n state\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n"
} }
}; };
})(); })();

View File

@@ -25,6 +25,7 @@ const fragment = graphql`
id id
role role
source source
state
profile @required(action: THROW) { profile @required(action: THROW) {
fullName fullName
} }
@@ -63,6 +64,8 @@ export function MemberListItem(props: {
const membership = useFragment<MemberListItemFragment$key>(fragment, fKey); const membership = useFragment<MemberListItemFragment$key>(fragment, fKey);
const { role } = use(CurrentUser); const { role } = use(CurrentUser);
const isInactive = membership.state === "INACTIVE";
// Only OWNER can edit OWNER members // Only OWNER can edit OWNER members
const canEditThisRole = membership.role === "OWNER" ? role === "OWNER" : true; const canEditThisRole = membership.role === "OWNER" ? role === "OWNER" : true;
@@ -98,9 +101,17 @@ export function MemberListItem(props: {
return ( return (
<> <>
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}> <Tr
className={clsx(
isRemoving && "opacity-60 pointer-events-none",
isInactive && "opacity-50"
)}
>
<Td> <Td>
<div className="font-semibold">{membership.profile.fullName}</div> <div className="flex items-center gap-2">
<span className="font-semibold">{membership.profile.fullName}</span>
{isInactive && <Badge variant="neutral">{__("Inactive")}</Badge>}
</div>
</Td> </Td>
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -113,34 +124,36 @@ export function MemberListItem(props: {
</Td> </Td>
<Td>{new Date(membership.createdAt).toLocaleDateString()}</Td> <Td>{new Date(membership.createdAt).toLocaleDateString()}</Td>
<Td noLink width={160} className="text-end"> <Td noLink width={160} className="text-end">
<div {!isInactive && (
className="flex gap-2 justify-end" <div
onClick={(e) => e.stopPropagation()} className="flex gap-2 justify-end"
> onClick={(e) => e.stopPropagation()}
{membership.canUpdate && canEditThisRole && ( >
<Button {membership.canUpdate && canEditThisRole && (
variant="secondary"
onClick={() => setDialogOpen(true)}
disabled={dialogOpen}
icon={IconPencil}
aria-label={__("Edit role")}
/>
)}
{isRemoving ? (
<Spinner size={16} />
) : (
membership.canDelete &&
canEditThisRole && (
<Button <Button
variant="danger" variant="secondary"
onClick={handleRemove} onClick={() => setDialogOpen(true)}
disabled={isRemoving} disabled={dialogOpen}
icon={IconTrashCan} icon={IconPencil}
aria-label={__("Remove member")} aria-label={__("Edit role")}
/> />
) )}
)} {isRemoving ? (
</div> <Spinner size={16} />
) : (
membership.canDelete &&
canEditThisRole && (
<Button
variant="danger"
onClick={handleRemove}
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
/>
)
)}
</div>
)}
</Td> </Td>
</Tr> </Tr>

View File

@@ -36,6 +36,7 @@ type (
OrganizationID gid.GID `db:"organization_id"` OrganizationID gid.GID `db:"organization_id"`
Role MembershipRole `db:"role"` Role MembershipRole `db:"role"`
Source MembershipSource `db:"source"` Source MembershipSource `db:"source"`
State MembershipState `db:"state"`
FullName string `db:"full_name"` FullName string `db:"full_name"`
EmailAddress mail.Addr `db:"email_address"` EmailAddress mail.Addr `db:"email_address"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
@@ -69,6 +70,7 @@ WITH mbr AS (
organization_id, organization_id,
role, role,
source, source,
state,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -83,6 +85,7 @@ SELECT
mbr.organization_id, mbr.organization_id,
mbr.role, mbr.role,
mbr.source, mbr.source,
mbr.state,
COALESCE(mp.full_name, i.full_name, '') as full_name, COALESCE(mp.full_name, i.full_name, '') as full_name,
i.email_address, i.email_address,
mbr.created_at, mbr.created_at,
@@ -126,6 +129,7 @@ INSERT INTO
organization_id, organization_id,
role, role,
source, source,
state,
created_at, created_at,
updated_at updated_at
) )
@@ -136,6 +140,7 @@ VALUES (
@organization_id, @organization_id,
@role, @role,
@source, @source,
@state,
@created_at, @created_at,
@updated_at @updated_at
); );
@@ -148,6 +153,7 @@ VALUES (
"organization_id": m.OrganizationID, "organization_id": m.OrganizationID,
"role": m.Role, "role": m.Role,
"source": m.Source, "source": m.Source,
"state": m.State,
"created_at": m.CreatedAt, "created_at": m.CreatedAt,
"updated_at": m.UpdatedAt, "updated_at": m.UpdatedAt,
} }
@@ -183,6 +189,7 @@ WITH mbr AS (
organization_id, organization_id,
role, role,
source, source,
state,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -197,6 +204,7 @@ SELECT
mbr.organization_id, mbr.organization_id,
mbr.role, mbr.role,
mbr.source, mbr.source,
mbr.state,
COALESCE(mp.full_name, i.full_name, '') as full_name, COALESCE(mp.full_name, i.full_name, '') as full_name,
i.email_address, i.email_address,
mbr.created_at, mbr.created_at,
@@ -334,6 +342,7 @@ WITH mbr AS (
am.organization_id, am.organization_id,
am.role, am.role,
am.source, am.source,
am.state,
am.created_at, am.created_at,
am.updated_at am.updated_at
FROM FROM
@@ -349,6 +358,7 @@ SELECT
mbr.organization_id, mbr.organization_id,
mbr.role, mbr.role,
mbr.source, mbr.source,
mbr.state,
COALESCE(mp.full_name, i.full_name, '') as full_name, COALESCE(mp.full_name, i.full_name, '') as full_name,
i.email_address, i.email_address,
mbr.created_at, mbr.created_at,
@@ -402,6 +412,7 @@ WITH mbr AS (
am.organization_id, am.organization_id,
am.role, am.role,
am.source, am.source,
am.state,
am.created_at, am.created_at,
am.updated_at am.updated_at
FROM FROM
@@ -419,6 +430,7 @@ SELECT
mbr.organization_id, mbr.organization_id,
mbr.role, mbr.role,
mbr.source, mbr.source,
mbr.state,
COALESCE(mp.full_name, i.full_name, '') as full_name, COALESCE(mp.full_name, i.full_name, '') as full_name,
i.email_address, i.email_address,
mbr.created_at, mbr.created_at,
@@ -464,6 +476,7 @@ UPDATE
SET SET
role = @role, role = @role,
source = @source, source = @source,
state = @state,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
id = @id id = @id
@@ -476,6 +489,7 @@ WHERE
"id": m.ID, "id": m.ID,
"role": m.Role, "role": m.Role,
"source": m.Source, "source": m.Source,
"state": m.State,
"updated_at": m.UpdatedAt, "updated_at": m.UpdatedAt,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
@@ -535,12 +549,14 @@ WITH mbr AS (
organization_id, organization_id,
role, role,
source, source,
state,
created_at, created_at,
updated_at updated_at
FROM FROM
iam_memberships iam_memberships
WHERE WHERE
identity_id = @identity_id identity_id = @identity_id
AND state = 'ACTIVE'
AND %s AND %s
ORDER BY ORDER BY
created_at DESC created_at DESC
@@ -551,6 +567,7 @@ SELECT
mbr.organization_id, mbr.organization_id,
mbr.role, mbr.role,
mbr.source, mbr.source,
mbr.state,
COALESCE(mp.full_name, i.full_name, '') as full_name, COALESCE(mp.full_name, i.full_name, '') as full_name,
i.email_address, i.email_address,
mbr.created_at, mbr.created_at,
@@ -603,6 +620,7 @@ WITH membership_with_profile AS (
m.organization_id, m.organization_id,
m.role, m.role,
m.source, m.source,
m.state,
COALESCE(mp.full_name, i.full_name, '') AS full_name, COALESCE(mp.full_name, i.full_name, '') AS full_name,
i.email_address, i.email_address,
m.created_at, m.created_at,
@@ -624,6 +642,7 @@ SELECT
organization_id, organization_id,
role, role,
source, source,
state,
full_name, full_name,
email_address, email_address,
created_at, created_at,
@@ -702,6 +721,7 @@ FROM
iam_memberships iam_memberships
WHERE WHERE
identity_id = @identity_id identity_id = @identity_id
AND state = 'ACTIVE'
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"identity_id": identityID, "identity_id": identityID,
@@ -728,6 +748,7 @@ SELECT
organization_id, organization_id,
role, role,
source, source,
state,
'' as full_name, '' as full_name,
NULL as email_address, NULL as email_address,
created_at, created_at,

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type MembershipState string
const (
MembershipStateActive MembershipState = "ACTIVE"
MembershipStateInactive MembershipState = "INACTIVE"
)
func (s MembershipState) String() string {
return string(s)
}
func (s *MembershipState) Scan(value any) error {
var str string
switch v := value.(type) {
case string:
str = v
case []byte:
str = string(v)
default:
return fmt.Errorf("unsupported type for MembershipState: %T", value)
}
switch str {
case "ACTIVE":
*s = MembershipStateActive
case "INACTIVE":
*s = MembershipStateInactive
default:
return fmt.Errorf("invalid MembershipState value: %q", str)
}
return nil
}
func (s MembershipState) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,5 @@
CREATE TYPE membership_state AS ENUM ('ACTIVE', 'INACTIVE');
ALTER TABLE iam_memberships ADD COLUMN state membership_state NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX idx_iam_memberships_state ON iam_memberships(state);

View File

@@ -243,32 +243,52 @@ func (s *AccountService) AcceptInvitation(
tenantID := invitation.OrganizationID.TenantID() tenantID := invitation.OrganizationID.TenantID()
scope := coredata.NewScope(invitation.OrganizationID.TenantID()) scope := coredata.NewScope(invitation.OrganizationID.TenantID())
membership = &coredata.Membership{ existingMembership := &coredata.Membership{}
ID: gid.New(tenantID, coredata.MembershipEntityType), err = existingMembership.LoadByIdentityAndOrg(ctx, tx, scope, identityID, invitation.OrganizationID)
IdentityID: identityID, if err != nil && err != coredata.ErrResourceNotFound {
OrganizationID: invitation.OrganizationID, return fmt.Errorf("cannot load existing membership: %w", err)
Role: invitation.Role,
Source: coredata.MembershipSourceManual,
CreatedAt: now,
UpdatedAt: now,
} }
err = membership.Insert(ctx, tx, scope) if existingMembership.ID != gid.Nil && existingMembership.State == coredata.MembershipStateInactive {
if err != nil { existingMembership.State = coredata.MembershipStateActive
return fmt.Errorf("cannot create membership: %w", err) existingMembership.Role = invitation.Role
} existingMembership.UpdatedAt = now
profile := &coredata.MembershipProfile{ err = existingMembership.Update(ctx, tx, scope)
ID: gid.New(tenantID, coredata.MembershipProfileEntityType), if err != nil {
MembershipID: membership.ID, return fmt.Errorf("cannot reactivate membership: %w", err)
FullName: identity.FullName, }
CreatedAt: now,
UpdatedAt: now,
}
err = profile.Insert(ctx, tx) membership = existingMembership
if err != nil { } else {
return fmt.Errorf("cannot insert profile: %w", err) membership = &coredata.Membership{
ID: gid.New(tenantID, coredata.MembershipEntityType),
IdentityID: identityID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
Source: coredata.MembershipSourceManual,
State: coredata.MembershipStateActive,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
profile := &coredata.MembershipProfile{
ID: gid.New(tenantID, coredata.MembershipProfileEntityType),
MembershipID: membership.ID,
FullName: identity.FullName,
CreatedAt: now,
UpdatedAt: now,
}
err = profile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert profile: %w", err)
}
} }
invitation.AcceptedAt = &now invitation.AcceptedAt = &now

View File

@@ -190,7 +190,7 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy {
func findRoleForOrg(memberships coredata.Memberships, orgID string) string { func findRoleForOrg(memberships coredata.Memberships, orgID string) string {
for _, m := range memberships { for _, m := range memberships {
if m.OrganizationID.String() == orgID { if m.OrganizationID.String() == orgID && m.State == coredata.MembershipStateActive {
return string(m.Role) return string(m.Role)
} }
} }

View File

@@ -123,6 +123,18 @@ func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership %q not found", e.MembershipID) return fmt.Sprintf("membership %q not found", e.MembershipID)
} }
type ErrMembershipInactive struct {
MembershipID gid.GID
}
func NewMembershipInactiveError(membershipID gid.GID) error {
return &ErrMembershipInactive{MembershipID: membershipID}
}
func (e ErrMembershipInactive) Error() string {
return fmt.Sprintf("membership %q is inactive", e.MembershipID)
}
type ErrOrganizationNotFound struct{ OrganizationID gid.GID } type ErrOrganizationNotFound struct{ OrganizationID gid.GID }
func NewOrganizationNotFoundError(organizationID gid.GID) error { func NewOrganizationNotFoundError(organizationID gid.GID) error {

View File

@@ -403,7 +403,7 @@ func (s *OrganizationService) InviteMember(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
if membership.ID != gid.Nil { if membership.ID != gid.Nil && membership.State == coredata.MembershipStateActive {
return NewMembershipAlreadyExistsError(identity.ID, organizationID) return NewMembershipAlreadyExistsError(identity.ID, organizationID)
} }
} }
@@ -493,6 +493,7 @@ func (s *OrganizationService) CreateOrganization(
OrganizationID: organizationID, OrganizationID: organizationID,
Role: coredata.MembershipRoleOwner, Role: coredata.MembershipRoleOwner,
Source: coredata.MembershipSourceManual, Source: coredata.MembershipSourceManual,
State: coredata.MembershipStateActive,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }

View File

@@ -88,3 +88,13 @@ func NewSAMLAutoSignupDisabledError(configID gid.GID) error {
func (e ErrSAMLAutoSignupDisabled) Error() string { func (e ErrSAMLAutoSignupDisabled) Error() string {
return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID) return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID)
} }
type ErrMembershipInactive struct{ MembershipID gid.GID }
func NewMembershipInactiveError(membershipID gid.GID) error {
return &ErrMembershipInactive{MembershipID: membershipID}
}
func (e ErrMembershipInactive) Error() string {
return fmt.Sprintf("membership %q is inactive", e.MembershipID)
}

View File

@@ -291,6 +291,10 @@ func (s *Service) HandleAssertion(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
if membership.ID != gid.Nil && membership.State == coredata.MembershipStateInactive {
return NewMembershipInactiveError(membership.ID)
}
isMember := membership.ID != gid.Nil isMember := membership.ID != gid.Nil
if !isMember { if !isMember {
membership = &coredata.Membership{ membership = &coredata.Membership{
@@ -299,6 +303,7 @@ func (s *Service) HandleAssertion(
OrganizationID: config.OrganizationID, OrganizationID: config.OrganizationID,
Role: coredata.MembershipRoleViewer, Role: coredata.MembershipRoleViewer,
Source: coredata.MembershipSourceSAML, Source: coredata.MembershipSourceSAML,
State: coredata.MembershipStateActive,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }

View File

@@ -71,16 +71,19 @@ func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SC
hashedToken := HashToken(token) hashedToken := HashToken(token)
config := &coredata.SCIMConfiguration{} config := &coredata.SCIMConfiguration{}
err := s.pg.WithConn(ctx, func(conn pg.Conn) error { err := s.pg.WithConn(
err := config.LoadByHashedToken(ctx, conn, hashedToken) ctx,
if err != nil { func(conn pg.Conn) error {
if err == coredata.ErrResourceNotFound { err := config.LoadByHashedToken(ctx, conn, hashedToken)
return NewSCIMInvalidTokenError() if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSCIMInvalidTokenError()
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
} }
return fmt.Errorf("cannot load SCIM configuration: %w", err) return nil
} },
return nil )
})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -89,7 +92,6 @@ func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SC
return config, nil return config, nil
} }
// CreateUser creates a new user via SCIM provisioning
func (s *Service) CreateUser( func (s *Service) CreateUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -147,6 +149,7 @@ func (s *Service) CreateUser(
OrganizationID: config.OrganizationID, OrganizationID: config.OrganizationID,
Role: coredata.MembershipRoleViewer, Role: coredata.MembershipRoleViewer,
Source: coredata.MembershipSourceSCIM, Source: coredata.MembershipSourceSCIM,
State: coredata.MembershipStateActive,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -172,8 +175,9 @@ func (s *Service) CreateUser(
} else if err != nil { } else if err != nil {
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} else { } else {
// Update existing membership source to SCIM // Update existing membership - reactivate if inactive, update source to SCIM
membership.Source = coredata.MembershipSourceSCIM membership.Source = coredata.MembershipSourceSCIM
membership.State = coredata.MembershipStateActive
membership.UpdatedAt = now membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope) err = membership.Update(ctx, tx, scope)
@@ -189,10 +193,9 @@ func (s *Service) CreateUser(
return scim.Resource{}, err return scim.Resource{}, err
} }
return membershipToResource(membership, true), nil return membershipToResource(membership), nil
} }
// GetUser gets a user by membership ID
func (s *Service) GetUser( func (s *Service) GetUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -214,7 +217,6 @@ func (s *Service) GetUser(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
// Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID { if membership.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(membershipID.String()) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
@@ -227,7 +229,7 @@ func (s *Service) GetUser(
return scim.Resource{}, err return scim.Resource{}, err
} }
return membershipToResource(membership, true), nil return membershipToResource(membership), nil
} }
// ListUsers lists all users in an organization, with optional filter support // ListUsers lists all users in an organization, with optional filter support
@@ -248,26 +250,29 @@ func (s *Service) ListUsers(
var memberships coredata.Memberships var memberships coredata.Memberships
var totalCount int var totalCount int
err = s.pg.WithConn(ctx, func(conn pg.Conn) error { err = s.pg.WithConn(
var err error ctx,
totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter) func(conn pg.Conn) error {
if err != nil { var err error
return fmt.Errorf("cannot count memberships: %w", err) totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter)
} if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
}
orderBy := page.OrderBy[coredata.MembershipOrderField]{ orderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt, Field: coredata.MembershipOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc, Direction: page.OrderDirectionDesc,
} }
cursor := page.NewCursor(count, nil, page.Head, orderBy) cursor := page.NewCursor(count, nil, page.Head, orderBy)
err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter) err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter)
if err != nil { if err != nil {
return fmt.Errorf("cannot load memberships: %w", err) return fmt.Errorf("cannot load memberships: %w", err)
} }
return nil return nil
}) },
)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -275,13 +280,12 @@ func (s *Service) ListUsers(
resources := make([]scim.Resource, 0, len(memberships)) resources := make([]scim.Resource, 0, len(memberships))
for _, m := range memberships { for _, m := range memberships {
resources = append(resources, membershipToResource(m, true)) resources = append(resources, membershipToResource(m))
} }
return resources, totalCount, nil return resources, totalCount, nil
} }
// ReplaceUser replaces a user via SCIM PUT
func (s *Service) ReplaceUser( func (s *Service) ReplaceUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -289,14 +293,13 @@ func (s *Service) ReplaceUser(
attributes scim.ResourceAttributes, attributes scim.ResourceAttributes,
) (scim.Resource, error) { ) (scim.Resource, error) {
fullName, active := ParseUserFromReplaceAttributes(attributes) fullName, active := ParseUserFromReplaceAttributes(attributes)
membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) membership, err := s.updateUser(ctx, config, membershipID, fullName, active)
if err != nil { if err != nil {
return scim.Resource{}, err return scim.Resource{}, err
} }
return membershipToResource(membership, !deactivated), nil return membershipToResource(membership), nil
} }
// PatchUser patches a user via SCIM PATCH
func (s *Service) PatchUser( func (s *Service) PatchUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -304,11 +307,11 @@ func (s *Service) PatchUser(
operations []scim.PatchOperation, operations []scim.PatchOperation,
) (scim.Resource, error) { ) (scim.Resource, error) {
fullName, active := ParseUserFromPatchOperations(operations) fullName, active := ParseUserFromPatchOperations(operations)
membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) membership, err := s.updateUser(ctx, config, membershipID, fullName, active)
if err != nil { if err != nil {
return scim.Resource{}, err return scim.Resource{}, err
} }
return membershipToResource(membership, !deactivated), nil return membershipToResource(membership), nil
} }
func (s *Service) updateUser( func (s *Service) updateUser(
@@ -317,12 +320,11 @@ func (s *Service) updateUser(
membershipID gid.GID, membershipID gid.GID,
fullName string, fullName string,
active *bool, active *bool,
) (*coredata.Membership, bool, error) { ) (*coredata.Membership, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
now := time.Now() now := time.Now()
var membership *coredata.Membership var membership *coredata.Membership
var deactivated bool
err := s.pg.WithTx(ctx, func(tx pg.Conn) error { err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
membership = &coredata.Membership{} membership = &coredata.Membership{}
@@ -334,35 +336,35 @@ func (s *Service) updateUser(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
// Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID { if membership.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(membershipID.String()) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
// Handle deactivation - Okta sends PATCH with active=false to deprovision users needsUpdate := false
if active != nil && !*active {
err = membership.Delete(ctx, tx, scope, membershipID) if active != nil {
if err != nil { if *active && membership.State == coredata.MembershipStateInactive {
return fmt.Errorf("cannot delete membership: %w", err) membership.State = coredata.MembershipStateActive
needsUpdate = true
} else if !*active && membership.State == coredata.MembershipStateActive {
membership.State = coredata.MembershipStateInactive
needsUpdate = true
} }
deactivated = true
return nil
} }
// Update membership source to SCIM if not already
if membership.Source != coredata.MembershipSourceSCIM { if membership.Source != coredata.MembershipSourceSCIM {
membership.Source = coredata.MembershipSourceSCIM membership.Source = coredata.MembershipSourceSCIM
membership.UpdatedAt = now needsUpdate = true
}
if needsUpdate {
membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope) err = membership.Update(ctx, tx, scope)
if err != nil { if err != nil {
return fmt.Errorf("cannot update membership: %w", err) return fmt.Errorf("cannot update membership: %w", err)
} }
} }
// Update membership profile
profile := &coredata.MembershipProfile{} profile := &coredata.MembershipProfile{}
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID) err = profile.LoadByMembershipID(ctx, tx, scope, membershipID)
if err == nil { if err == nil {
@@ -381,13 +383,12 @@ func (s *Service) updateUser(
}) })
if err != nil { if err != nil {
return nil, false, err return nil, err
} }
return membership, deactivated, nil return membership, nil
} }
// DeleteUser removes a user's membership from the organization
func (s *Service) DeleteUser( func (s *Service) DeleteUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -395,31 +396,32 @@ func (s *Service) DeleteUser(
) error { ) error {
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
return s.pg.WithTx(ctx, func(tx pg.Conn) error { return s.pg.WithTx(
membership := &coredata.Membership{} ctx,
err := membership.LoadByID(ctx, tx, scope, membershipID) func(tx pg.Conn) error {
if err != nil { membership := &coredata.Membership{}
if err == coredata.ErrResourceNotFound { err := membership.LoadByID(ctx, tx, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
}
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(membershipID.String()) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
return fmt.Errorf("cannot load membership: %w", err)
}
// Verify membership belongs to this organization err = membership.Delete(ctx, tx, scope, membershipID)
if membership.OrganizationID != config.OrganizationID { if err != nil {
return scimerrors.ScimErrorResourceNotFound(membershipID.String()) return fmt.Errorf("cannot delete membership: %w", err)
} }
err = membership.Delete(ctx, tx, scope, membershipID) return nil
if err != nil { },
return fmt.Errorf("cannot delete membership: %w", err) )
}
return nil
})
} }
// LogEvent logs a SCIM event
func (s *Service) LogEvent( func (s *Service) LogEvent(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
@@ -439,9 +441,16 @@ func (s *Service) LogEvent(
event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage) event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage)
err := s.pg.WithConn(ctx, func(conn pg.Conn) error { err := s.pg.WithConn(
return event.Insert(ctx, conn, scope) ctx,
}) func(conn pg.Conn) error {
err := event.Insert(ctx, conn, scope)
if err != nil {
return fmt.Errorf("cannot insert SCIM event: %w", err)
}
return nil
},
)
if err != nil { if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
@@ -476,22 +485,21 @@ func (s *Service) createEvent(
return event return event
} }
// ParseUserFromAttributes extracts user data from SCIM create attributes
func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, fullName string) { func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, fullName string) {
userName, _ := attributes["userName"].(string) userName, _ := attributes["userName"].(string)
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
var givenName, familyName string var givenName, familyName string
if name, ok := attributes["name"].(map[string]interface{}); ok { if name, ok := attributes["name"].(map[string]any); ok {
givenName, _ = name["givenName"].(string) givenName, _ = name["givenName"].(string)
familyName, _ = name["familyName"].(string) familyName, _ = name["familyName"].(string)
} }
// Get email from emails array or use userName // Get email from emails array or use userName
email = userName email = userName
if emails, ok := attributes["emails"].([]interface{}); ok && len(emails) > 0 { if emails, ok := attributes["emails"].([]any); ok && len(emails) > 0 {
for _, e := range emails { for _, e := range emails {
if emailMap, ok := e.(map[string]interface{}); ok { if emailMap, ok := e.(map[string]any); ok {
if primary, _ := emailMap["primary"].(bool); primary { if primary, _ := emailMap["primary"].(bool); primary {
if value, ok := emailMap["value"].(string); ok { if value, ok := emailMap["value"].(string); ok {
email = value email = value
@@ -502,7 +510,7 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
} }
// If no primary email found, use the first one // If no primary email found, use the first one
if email == userName { if email == userName {
if emailMap, ok := emails[0].(map[string]interface{}); ok { if emailMap, ok := emails[0].(map[string]any); ok {
if value, ok := emailMap["value"].(string); ok { if value, ok := emailMap["value"].(string); ok {
email = value email = value
} }
@@ -522,12 +530,11 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
return email, fullName return email, fullName
} }
// ParseUserFromReplaceAttributes extracts user data from SCIM replace (PUT) attributes
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool) { func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool) {
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
var givenName, familyName string var givenName, familyName string
if name, ok := attributes["name"].(map[string]interface{}); ok { if name, ok := attributes["name"].(map[string]any); ok {
givenName, _ = name["givenName"].(string) givenName, _ = name["givenName"].(string)
familyName, _ = name["familyName"].(string) familyName, _ = name["familyName"].(string)
} }
@@ -545,7 +552,6 @@ func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullNam
return fullName, &activeVal return fullName, &activeVal
} }
// ParseUserFromPatchOperations extracts user data from SCIM patch operations
func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool) { func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool) {
var givenName, familyName string var givenName, familyName string
@@ -555,6 +561,29 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
if op.Path != nil { if op.Path != nil {
path = op.Path.String() path = op.Path.String()
} }
// Handle empty path with value map (Okta style)
// e.g., { "op": "Replace", "value": { "active": false } }
if path == "" {
if valueMap, ok := op.Value.(map[string]any); ok {
if a, ok := valueMap["active"].(bool); ok {
active = &a
}
if name, ok := valueMap["displayName"].(string); ok {
fullName = name
}
if nameMap, ok := valueMap["name"].(map[string]any); ok {
if gn, ok := nameMap["givenName"].(string); ok {
givenName = gn
}
if fn, ok := nameMap["familyName"].(string); ok {
familyName = fn
}
}
}
continue
}
switch strings.ToLower(path) { switch strings.ToLower(path) {
case "active": case "active":
if a, ok := op.Value.(bool); ok { if a, ok := op.Value.(bool); ok {
@@ -584,14 +613,14 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
return fullName, active return fullName, active
} }
func membershipToResource(m *coredata.Membership, active bool) scim.Resource { func membershipToResource(m *coredata.Membership) scim.Resource {
return scim.Resource{ return scim.Resource{
ID: m.ID.String(), ID: m.ID.String(),
ExternalID: optional.NewString(m.ID.String()), ExternalID: optional.NewString(m.ID.String()),
Attributes: scim.ResourceAttributes{ Attributes: scim.ResourceAttributes{
"userName": m.EmailAddress.String(), "userName": m.EmailAddress.String(),
"displayName": m.FullName, "displayName": m.FullName,
"active": active, "active": m.State == coredata.MembershipStateActive,
"name": map[string]any{ "name": map[string]any{
"formatted": m.FullName, "formatted": m.FullName,
}, },

View File

@@ -367,6 +367,10 @@ func (s SessionService) OpenSAMLChildSessionForOrganization(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
if membership.State == coredata.MembershipStateInactive {
return NewMembershipInactiveError(membership.ID)
}
tenantID := scope.GetTenantID() tenantID := scope.GetTenantID()
childSession = &coredata.Session{ childSession = &coredata.Session{
ID: gid.New(tenantID, coredata.SessionEntityType), ID: gid.New(tenantID, coredata.SessionEntityType),
@@ -442,6 +446,10 @@ func (s SessionService) AssumeOrganizationSession(
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
if membership.State == coredata.MembershipStateInactive {
return NewMembershipInactiveError(membership.ID)
}
samlConfig := &coredata.SAMLConfiguration{} samlConfig := &coredata.SAMLConfiguration{}
err = samlConfig.LoadByOrganizationIDAndEmailDomain( err = samlConfig.LoadByOrganizationIDAndEmailDomain(
ctx, ctx,

View File

@@ -254,6 +254,13 @@ enum MembershipSource
SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM")
} }
enum MembershipState
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") {
ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive")
INACTIVE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive")
}
type Membership implements Node { type Membership implements Node {
id: ID! id: ID!
createdAt: Datetime! createdAt: Datetime!
@@ -262,6 +269,7 @@ type Membership implements Node {
organization: Organization @goField(forceResolver: true) organization: Organization @goField(forceResolver: true)
role: MembershipRole! role: MembershipRole!
source: MembershipSource! source: MembershipSource!
state: MembershipState!
lastSession: Session @goField(forceResolver: true) @isViewer lastSession: Session @goField(forceResolver: true) @isViewer

View File

@@ -183,6 +183,7 @@ type ComplexityRoot struct {
Profile func(childComplexity int) int Profile func(childComplexity int) int
Role func(childComplexity int) int Role func(childComplexity int) int
Source func(childComplexity int) int Source func(childComplexity int) int
State func(childComplexity int) int
} }
MembershipConnection struct { MembershipConnection struct {
@@ -964,6 +965,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
} }
return e.complexity.Membership.Source(childComplexity), true return e.complexity.Membership.Source(childComplexity), true
case "Membership.state":
if e.complexity.Membership.State == nil {
break
}
return e.complexity.Membership.State(childComplexity), true
case "MembershipConnection.edges": case "MembershipConnection.edges":
if e.complexity.MembershipConnection.Edges == nil { if e.complexity.MembershipConnection.Edges == nil {
@@ -2533,6 +2540,13 @@ enum MembershipSource
SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM")
} }
enum MembershipState
@goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") {
ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive")
INACTIVE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive")
}
type Membership implements Node { type Membership implements Node {
id: ID! id: ID!
createdAt: Datetime! createdAt: Datetime!
@@ -2541,6 +2555,7 @@ type Membership implements Node {
organization: Organization @goField(forceResolver: true) organization: Organization @goField(forceResolver: true)
role: MembershipRole! role: MembershipRole!
source: MembershipSource! source: MembershipSource!
state: MembershipState!
lastSession: Session @goField(forceResolver: true) @isViewer lastSession: Session @goField(forceResolver: true) @isViewer
@@ -5832,6 +5847,35 @@ func (ec *executionContext) fieldContext_Membership_source(_ context.Context, fi
return fc, nil return fc, nil
} }
func (ec *executionContext) _Membership_state(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_Membership_state,
func(ctx context.Context) (any, error) {
return obj.State, nil
},
nil,
ec.marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState,
true,
true,
)
}
func (ec *executionContext) fieldContext_Membership_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Membership",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type MembershipState does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Membership_lastSession(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { func (ec *executionContext) _Membership_lastSession(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -6092,6 +6136,8 @@ func (ec *executionContext) fieldContext_MembershipEdge_node(_ context.Context,
return ec.fieldContext_Membership_role(ctx, field) return ec.fieldContext_Membership_role(ctx, field)
case "source": case "source":
return ec.fieldContext_Membership_source(ctx, field) return ec.fieldContext_Membership_source(ctx, field)
case "state":
return ec.fieldContext_Membership_state(ctx, field)
case "lastSession": case "lastSession":
return ec.fieldContext_Membership_lastSession(ctx, field) return ec.fieldContext_Membership_lastSession(ctx, field)
case "permission": case "permission":
@@ -8622,6 +8668,8 @@ func (ec *executionContext) fieldContext_Organization_viewerMembership(_ context
return ec.fieldContext_Membership_role(ctx, field) return ec.fieldContext_Membership_role(ctx, field)
case "source": case "source":
return ec.fieldContext_Membership_source(ctx, field) return ec.fieldContext_Membership_source(ctx, field)
case "state":
return ec.fieldContext_Membership_state(ctx, field)
case "lastSession": case "lastSession":
return ec.fieldContext_Membership_lastSession(ctx, field) return ec.fieldContext_Membership_lastSession(ctx, field)
case "permission": case "permission":
@@ -8777,6 +8825,8 @@ func (ec *executionContext) fieldContext_OrganizationSessionCreated_membership(_
return ec.fieldContext_Membership_role(ctx, field) return ec.fieldContext_Membership_role(ctx, field)
case "source": case "source":
return ec.fieldContext_Membership_source(ctx, field) return ec.fieldContext_Membership_source(ctx, field)
case "state":
return ec.fieldContext_Membership_state(ctx, field)
case "lastSession": case "lastSession":
return ec.fieldContext_Membership_lastSession(ctx, field) return ec.fieldContext_Membership_lastSession(ctx, field)
case "permission": case "permission":
@@ -11175,6 +11225,8 @@ func (ec *executionContext) fieldContext_SCIMEvent_membership(_ context.Context,
return ec.fieldContext_Membership_role(ctx, field) return ec.fieldContext_Membership_role(ctx, field)
case "source": case "source":
return ec.fieldContext_Membership_source(ctx, field) return ec.fieldContext_Membership_source(ctx, field)
case "state":
return ec.fieldContext_Membership_state(ctx, field)
case "lastSession": case "lastSession":
return ec.fieldContext_Membership_lastSession(ctx, field) return ec.fieldContext_Membership_lastSession(ctx, field)
case "permission": case "permission":
@@ -12326,6 +12378,8 @@ func (ec *executionContext) fieldContext_UpdateMembershipPayload_membership(_ co
return ec.fieldContext_Membership_role(ctx, field) return ec.fieldContext_Membership_role(ctx, field)
case "source": case "source":
return ec.fieldContext_Membership_source(ctx, field) return ec.fieldContext_Membership_source(ctx, field)
case "state":
return ec.fieldContext_Membership_state(ctx, field)
case "lastSession": case "lastSession":
return ec.fieldContext_Membership_lastSession(ctx, field) return ec.fieldContext_Membership_lastSession(ctx, field)
case "permission": case "permission":
@@ -16471,6 +16525,11 @@ func (ec *executionContext) _Membership(ctx context.Context, sel ast.SelectionSe
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1) atomic.AddUint32(&out.Invalids, 1)
} }
case "state":
out.Values[i] = ec._Membership_state(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "lastSession": case "lastSession":
field := field field := field
@@ -20219,6 +20278,34 @@ var (
} }
) )
func (ec *executionContext) unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, v any) (coredata.MembershipState, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[tmp]
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipState) graphql.Marshaler {
_ = sel
res := graphql.MarshalString(marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[v])
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
}
return res
}
var (
unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[string]coredata.MembershipState{
"ACTIVE": coredata.MembershipStateActive,
"INACTIVE": coredata.MembershipStateInactive,
}
marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[coredata.MembershipState]string{
coredata.MembershipStateActive: "ACTIVE",
coredata.MembershipStateInactive: "INACTIVE",
}
)
func (ec *executionContext) unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection(ctx context.Context, v any) (page.OrderDirection, error) { func (ec *executionContext) unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection(ctx context.Context, v any) (page.OrderDirection, error) {
tmp, err := graphql.UnmarshalString(v) tmp, err := graphql.UnmarshalString(v)
res := unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection[tmp] res := unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection[tmp]

View File

@@ -74,5 +74,6 @@ func NewMembership(membership *coredata.Membership) *Membership {
}, },
Role: membership.Role, Role: membership.Role,
Source: membership.Source, Source: membership.Source,
State: membership.State,
} }
} }

View File

@@ -211,6 +211,7 @@ type Membership struct {
Organization *Organization `json:"organization,omitempty"` Organization *Organization `json:"organization,omitempty"`
Role coredata.MembershipRole `json:"role"` Role coredata.MembershipRole `json:"role"`
Source coredata.MembershipSource `json:"source"` Source coredata.MembershipSource `json:"source"`
State coredata.MembershipState `json:"state"`
LastSession *Session `json:"lastSession,omitempty"` LastSession *Session `json:"lastSession,omitempty"`
Permission bool `json:"permission"` Permission bool `json:"permission"`
} }