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:
Sacha Al Himdani
2026-05-27 19:35:50 +02:00
parent f0b9acb748
commit e6b40957ee
11 changed files with 436 additions and 0 deletions

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />
))}

View 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())
})
}

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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