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 type { MembershipsPageQuery } from "#/__generated__/iam/MembershipsPageQuery.graphql";
|
||||||
|
|
||||||
|
import { InvitingOrganizationCard } from "./_components/InvitingOrganizationCard";
|
||||||
import { MembershipCard } from "./_components/MembershipCard";
|
import { MembershipCard } from "./_components/MembershipCard";
|
||||||
|
|
||||||
export const membershipsPageQuery = graphql`
|
export const membershipsPageQuery = graphql`
|
||||||
@@ -49,6 +50,10 @@ export const membershipsPageQuery = graphql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
invitingOrganizations {
|
||||||
|
id
|
||||||
|
...InvitingOrganizationCardFragment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -65,6 +70,7 @@ export function MembershipsPage(props: {
|
|||||||
const {
|
const {
|
||||||
viewer: {
|
viewer: {
|
||||||
profiles: { edges: initialProfiles },
|
profiles: { edges: initialProfiles },
|
||||||
|
invitingOrganizations,
|
||||||
},
|
},
|
||||||
} = usePreloadedQuery<MembershipsPageQuery>(membershipsPageQuery, queryRef);
|
} = usePreloadedQuery<MembershipsPageQuery>(membershipsPageQuery, queryRef);
|
||||||
|
|
||||||
@@ -84,6 +90,16 @@ export function MembershipsPage(props: {
|
|||||||
{__("Select an organization")}
|
{__("Select an organization")}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-4 w-full">
|
<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 && (
|
{initialProfiles.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-xl font-semibold">
|
<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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { DropdownSeparator } from "@probo/ui";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
|
|
||||||
import type { MembershipsDropdownMenuQuery } from "#/__generated__/iam/MembershipsDropdownMenuQuery.graphql";
|
import type { MembershipsDropdownMenuQuery } from "#/__generated__/iam/MembershipsDropdownMenuQuery.graphql";
|
||||||
|
|
||||||
|
import { MembershipsDropdownInvitingItem } from "./MembershipsDropdownInvitingItem";
|
||||||
import { MembershipsDropdownMenuItem } from "./MembershipsDropdownMenuItem";
|
import { MembershipsDropdownMenuItem } from "./MembershipsDropdownMenuItem";
|
||||||
|
|
||||||
export const membershipsDropdownMenuQuery = graphql`
|
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) {
|
export function MembershipsDropdownMenu(props: MembershipsDropdownMenuProps) {
|
||||||
const { queryRef, search } = props;
|
const { queryRef, search } = props;
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
viewer: {
|
viewer: {
|
||||||
profiles: { edges: initialProfiles },
|
profiles: { edges: initialProfiles },
|
||||||
|
invitingOrganizations: initialInvitingOrganizations,
|
||||||
},
|
},
|
||||||
} = usePreloadedQuery<MembershipsDropdownMenuQuery>(
|
} = usePreloadedQuery<MembershipsDropdownMenuQuery>(
|
||||||
membershipsDropdownMenuQuery,
|
membershipsDropdownMenuQuery,
|
||||||
@@ -71,8 +81,29 @@ export function MembershipsDropdownMenu(props: MembershipsDropdownMenuProps) {
|
|||||||
);
|
);
|
||||||
}, [initialProfiles, search]);
|
}, [initialProfiles, search]);
|
||||||
|
|
||||||
|
const invitingOrganizations = useMemo(() => {
|
||||||
|
if (!search) {
|
||||||
|
return initialInvitingOrganizations;
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialInvitingOrganizations.filter(organization =>
|
||||||
|
organization.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [initialInvitingOrganizations, search]);
|
||||||
|
|
||||||
return (
|
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 }) => (
|
{profiles.map(({ node }) => (
|
||||||
<MembershipsDropdownMenuItem fKey={node.membership} organizationFragmentRef={node.organization} key={node.id} />
|
<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
|
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 {
|
func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||||
c.T.Helper()
|
c.T.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -530,6 +530,10 @@ func NewClientWithNewSession(t testing.TB, from *Client) *Client {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetEmail() string {
|
||||||
|
return c.email
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetUserID() gid.GID {
|
func (c *Client) GetUserID() gid.GID {
|
||||||
return c.userID
|
return c.userID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,66 @@ WHERE
|
|||||||
return nil
|
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(
|
func (o *Organization) Insert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
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) {
|
func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GID) ([]*coredata.Organization, error) {
|
||||||
var organizations coredata.Organizations
|
var organizations coredata.Organizations
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ type Identity implements Node {
|
|||||||
before: CursorKey
|
before: CursorKey
|
||||||
): PersonalAPIKeyConnection @goField(forceResolver: true)
|
): PersonalAPIKeyConnection @goField(forceResolver: true)
|
||||||
|
|
||||||
|
invitingOrganizations: [Organization!]!
|
||||||
|
@goField(forceResolver: true)
|
||||||
|
@session(required: PRESENT)
|
||||||
|
|
||||||
ssoLoginURL: String
|
ssoLoginURL: String
|
||||||
@goField(forceResolver: true)
|
@goField(forceResolver: true)
|
||||||
@session(required: PRESENT)
|
@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
|
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.
|
// SsoLoginURL is the resolver for the ssoLoginURL field.
|
||||||
func (r *identityResolver) SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) {
|
func (r *identityResolver) SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) {
|
||||||
if _, err := r.authorize(ctx, obj.ID, iam.ActionIdentityGet); err != nil {
|
if _, err := r.authorize(ctx, obj.ID, iam.ActionIdentityGet); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user