Add invitingOrganizations field on viewer
Expose viewer.invitingOrganizations: [Organization!]! returning the organizations that have a live pending invitation directed at the current identity (accepted_at IS NULL AND expires_at > NOW()). The list is rendered under a "Pending invitations" section on the memberships page and in the organization selector dropdown, so a user already signed in with an existing identity can see which organizations have invited them without having to dig through their inbox. The new field is gated by iam:invitation:list against the viewer's own identity, so it does not loosen authorization on Organization elsewhere. E2E coverage validates the live-pending case, the no-invitation and post-accept cases, and a multi-org scenario. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { MembershipsPageQuery } from "#/__generated__/iam/MembershipsPageQuery.graphql";
|
||||
|
||||
import { InvitingOrganizationCard } from "./_components/InvitingOrganizationCard";
|
||||
import { MembershipCard } from "./_components/MembershipCard";
|
||||
|
||||
export const membershipsPageQuery = graphql`
|
||||
@@ -49,6 +50,10 @@ export const membershipsPageQuery = graphql`
|
||||
}
|
||||
}
|
||||
}
|
||||
invitingOrganizations {
|
||||
id
|
||||
...InvitingOrganizationCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -65,6 +70,7 @@ export function MembershipsPage(props: {
|
||||
const {
|
||||
viewer: {
|
||||
profiles: { edges: initialProfiles },
|
||||
invitingOrganizations,
|
||||
},
|
||||
} = usePreloadedQuery<MembershipsPageQuery>(membershipsPageQuery, queryRef);
|
||||
|
||||
@@ -84,6 +90,16 @@ export function MembershipsPage(props: {
|
||||
{__("Select an organization")}
|
||||
</h1>
|
||||
<div className="space-y-4 w-full">
|
||||
{invitingOrganizations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{__("Pending invitations")}
|
||||
</h2>
|
||||
{invitingOrganizations.map(organization => (
|
||||
<InvitingOrganizationCard key={organization.id} fKey={organization} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{initialProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 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.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Card, IconMail } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { InvitingOrganizationCardFragment$key } from "#/__generated__/iam/InvitingOrganizationCardFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment InvitingOrganizationCardFragment on Organization {
|
||||
name
|
||||
}
|
||||
`;
|
||||
|
||||
interface InvitingOrganizationCardProps {
|
||||
fKey: InvitingOrganizationCardFragment$key;
|
||||
}
|
||||
|
||||
export function InvitingOrganizationCard(props: InvitingOrganizationCardProps) {
|
||||
const { fKey } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const organization = useFragment<InvitingOrganizationCardFragment$key>(
|
||||
fragment,
|
||||
fKey,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padded className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-xl">{organization.name}</h2>
|
||||
<Badge variant="neutral" className="flex items-center gap-1">
|
||||
<IconMail size={14} />
|
||||
{__("Check your email")}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 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.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { IconMail } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { MembershipsDropdownInvitingItemFragment$key } from "#/__generated__/iam/MembershipsDropdownInvitingItemFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment MembershipsDropdownInvitingItemFragment on Organization {
|
||||
name
|
||||
}
|
||||
`;
|
||||
|
||||
export function MembershipsDropdownInvitingItem(props: {
|
||||
fKey: MembershipsDropdownInvitingItemFragment$key;
|
||||
}) {
|
||||
const { fKey } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const organization = useFragment<MembershipsDropdownInvitingItemFragment$key>(
|
||||
fragment,
|
||||
fKey,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-txt-primary flex items-center gap-2 p-2 cursor-default"
|
||||
title={__("Check your email to accept the invitation")}
|
||||
>
|
||||
<div className="bg-border-mid text-txt-invert! rounded-full size-6 flex items-center justify-center flex-none">
|
||||
<IconMail size={16} />
|
||||
</div>
|
||||
<span className="flex-1">{organization.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,14 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { DropdownSeparator } from "@probo/ui";
|
||||
import { useMemo } from "react";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { MembershipsDropdownMenuQuery } from "#/__generated__/iam/MembershipsDropdownMenuQuery.graphql";
|
||||
|
||||
import { MembershipsDropdownInvitingItem } from "./MembershipsDropdownInvitingItem";
|
||||
import { MembershipsDropdownMenuItem } from "./MembershipsDropdownMenuItem";
|
||||
|
||||
export const membershipsDropdownMenuQuery = graphql`
|
||||
@@ -40,6 +43,11 @@ export const membershipsDropdownMenuQuery = graphql`
|
||||
}
|
||||
}
|
||||
}
|
||||
invitingOrganizations {
|
||||
id
|
||||
name
|
||||
...MembershipsDropdownInvitingItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -51,10 +59,12 @@ interface MembershipsDropdownMenuProps {
|
||||
|
||||
export function MembershipsDropdownMenu(props: MembershipsDropdownMenuProps) {
|
||||
const { queryRef, search } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const {
|
||||
viewer: {
|
||||
profiles: { edges: initialProfiles },
|
||||
invitingOrganizations: initialInvitingOrganizations,
|
||||
},
|
||||
} = usePreloadedQuery<MembershipsDropdownMenuQuery>(
|
||||
membershipsDropdownMenuQuery,
|
||||
@@ -71,8 +81,29 @@ export function MembershipsDropdownMenu(props: MembershipsDropdownMenuProps) {
|
||||
);
|
||||
}, [initialProfiles, search]);
|
||||
|
||||
const invitingOrganizations = useMemo(() => {
|
||||
if (!search) {
|
||||
return initialInvitingOrganizations;
|
||||
}
|
||||
|
||||
return initialInvitingOrganizations.filter(organization =>
|
||||
organization.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [initialInvitingOrganizations, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{invitingOrganizations.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs text-txt-tertiary uppercase">
|
||||
{__("Pending invitations")}
|
||||
</div>
|
||||
{invitingOrganizations.map(organization => (
|
||||
<MembershipsDropdownInvitingItem key={organization.id} fKey={organization} />
|
||||
))}
|
||||
<DropdownSeparator />
|
||||
</>
|
||||
)}
|
||||
{profiles.map(({ node }) => (
|
||||
<MembershipsDropdownMenuItem fKey={node.membership} organizationFragmentRef={node.organization} key={node.id} />
|
||||
))}
|
||||
|
||||
144
e2e/console/inviting_organizations_test.go
Normal file
144
e2e/console/inviting_organizations_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 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 console_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
const invitingOrganizationsQuery = `
|
||||
query {
|
||||
viewer {
|
||||
invitingOrganizations {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type invitingOrganizationsResult struct {
|
||||
Viewer struct {
|
||||
InvitingOrganizations []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"invitingOrganizations"`
|
||||
} `json:"viewer"`
|
||||
}
|
||||
|
||||
func TestInvitingOrganizations_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("includes orgs with a live pending invitation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inviter := testutil.NewClient(t, testutil.RoleOwner)
|
||||
invitee := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
profileID := factory.CreateUser(inviter, factory.Attrs{
|
||||
"emailAddress": invitee.GetEmail(),
|
||||
})
|
||||
factory.InviteUser(inviter, profileID)
|
||||
|
||||
var result invitingOrganizationsResult
|
||||
|
||||
err := invitee.ExecuteConnect(invitingOrganizationsQuery, nil, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, result.Viewer.InvitingOrganizations, 1)
|
||||
assert.Equal(t, inviter.GetOrganizationID().String(), result.Viewer.InvitingOrganizations[0].ID)
|
||||
})
|
||||
|
||||
t.Run("is empty when no invitation was sent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inviter := testutil.NewClient(t, testutil.RoleOwner)
|
||||
invitee := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Profile created but no invitation sent — invitation is the trigger.
|
||||
factory.CreateUser(inviter, factory.Attrs{
|
||||
"emailAddress": invitee.GetEmail(),
|
||||
})
|
||||
|
||||
var result invitingOrganizationsResult
|
||||
|
||||
err := invitee.ExecuteConnect(invitingOrganizationsQuery, nil, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.Viewer.InvitingOrganizations)
|
||||
})
|
||||
|
||||
t.Run("is empty for an identity with no invitations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
var result invitingOrganizationsResult
|
||||
|
||||
err := owner.ExecuteConnect(invitingOrganizationsQuery, nil, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.Viewer.InvitingOrganizations)
|
||||
})
|
||||
|
||||
t.Run("excludes orgs once the invitation is accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// NewClientInOrg goes through the full invite + activation flow, so the
|
||||
// invitation is in ACCEPTED state by the time it returns.
|
||||
member := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
var result invitingOrganizationsResult
|
||||
|
||||
err := member.ExecuteConnect(invitingOrganizationsQuery, nil, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.Viewer.InvitingOrganizations)
|
||||
})
|
||||
|
||||
t.Run("lists multiple orgs when invited by several", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inviter1 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
inviter2 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
invitee := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
profile1 := factory.CreateUser(inviter1, factory.Attrs{"emailAddress": invitee.GetEmail()})
|
||||
factory.InviteUser(inviter1, profile1)
|
||||
|
||||
profile2 := factory.CreateUser(inviter2, factory.Attrs{"emailAddress": invitee.GetEmail()})
|
||||
factory.InviteUser(inviter2, profile2)
|
||||
|
||||
var result invitingOrganizationsResult
|
||||
|
||||
err := invitee.ExecuteConnect(invitingOrganizationsQuery, nil, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
ids := make(map[string]struct{}, len(result.Viewer.InvitingOrganizations))
|
||||
for _, org := range result.Viewer.InvitingOrganizations {
|
||||
ids[org.ID] = struct{}{}
|
||||
}
|
||||
|
||||
assert.Contains(t, ids, inviter1.GetOrganizationID().String())
|
||||
assert.Contains(t, ids, inviter2.GetOrganizationID().String())
|
||||
})
|
||||
}
|
||||
@@ -151,6 +151,40 @@ func CreateUser(c *testutil.Client, attrs ...Attrs) string {
|
||||
return result.CreateUser.ProfileEdge.Node.ID
|
||||
}
|
||||
|
||||
func InviteUser(c *testutil.Client, profileID string) string {
|
||||
c.T.Helper()
|
||||
|
||||
const query = `
|
||||
mutation($input: InviteUserInput!) {
|
||||
inviteUser(input: $input) {
|
||||
invitationEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
InviteUser struct {
|
||||
InvitationEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"invitationEdge"`
|
||||
} `json:"inviteUser"`
|
||||
}
|
||||
|
||||
err := c.ExecuteConnect(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": c.GetOrganizationID().String(),
|
||||
"profileId": profileID,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(c.T, err, "inviteUser mutation failed")
|
||||
|
||||
return result.InviteUser.InvitationEdge.Node.ID
|
||||
}
|
||||
|
||||
func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||
c.T.Helper()
|
||||
|
||||
|
||||
@@ -530,6 +530,10 @@ func NewClientWithNewSession(t testing.TB, from *Client) *Client {
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) GetEmail() string {
|
||||
return c.email
|
||||
}
|
||||
|
||||
func (c *Client) GetUserID() gid.GID {
|
||||
return c.userID
|
||||
}
|
||||
|
||||
@@ -255,6 +255,66 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organizations) LoadAllByIdentityIDWithPendingInvitation(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
identityID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH invited_org AS (
|
||||
SELECT DISTINCT
|
||||
p.organization_id
|
||||
FROM
|
||||
iam_membership_profiles p
|
||||
INNER JOIN iam_invitations inv ON inv.user_id = p.id
|
||||
WHERE
|
||||
p.identity_id = @identity_id
|
||||
AND inv.accepted_at IS NULL
|
||||
AND inv.expires_at > NOW()
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
invited_org ON organizations.id = invited_org.organization_id
|
||||
WHERE
|
||||
%s
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"identity_id": identityID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organization) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
|
||||
@@ -600,6 +600,27 @@ func (s *AccountService) DeletePersonalAPIKey(
|
||||
)
|
||||
}
|
||||
|
||||
func (s AccountService) ListInvitingOrganizations(ctx context.Context, identityID gid.GID) ([]*coredata.Organization, error) {
|
||||
var organizations coredata.Organizations
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
err := organizations.LoadAllByIdentityIDWithPendingInvitation(ctx, conn, coredata.NewNoScope(), identityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load inviting organizations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizations, nil
|
||||
}
|
||||
|
||||
func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GID) ([]*coredata.Organization, error) {
|
||||
var organizations coredata.Organizations
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ type Identity implements Node {
|
||||
before: CursorKey
|
||||
): PersonalAPIKeyConnection @goField(forceResolver: true)
|
||||
|
||||
invitingOrganizations: [Organization!]!
|
||||
@goField(forceResolver: true)
|
||||
@session(required: PRESENT)
|
||||
|
||||
ssoLoginURL: String
|
||||
@goField(forceResolver: true)
|
||||
@session(required: PRESENT)
|
||||
|
||||
@@ -130,6 +130,26 @@ func (r *identityResolver) PersonalAPIKeys(ctx context.Context, obj *types.Ident
|
||||
return types.NewPersonalAPIKeyConnection(page, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// InvitingOrganizations is the resolver for the invitingOrganizations field.
|
||||
func (r *identityResolver) InvitingOrganizations(ctx context.Context, obj *types.Identity) ([]*types.Organization, error) {
|
||||
if _, err := r.authorize(ctx, obj.ID, iam.ActionInvitationList, authz.WithSkipAssumptionCheck()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
organizations, err := r.iam.AccountService.ListInvitingOrganizations(ctx, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list inviting organizations", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
result := make([]*types.Organization, len(organizations))
|
||||
for i, organization := range organizations {
|
||||
result[i] = types.NewOrganization(organization)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SsoLoginURL is the resolver for the ssoLoginURL field.
|
||||
func (r *identityResolver) SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) {
|
||||
if _, err := r.authorize(ctx, obj.ID, iam.ActionIdentityGet); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user