diff --git a/apps/console/src/pages/iam/memberships/MembershipsPage.tsx b/apps/console/src/pages/iam/memberships/MembershipsPage.tsx index 65e5b2288..8744e6a8e 100644 --- a/apps/console/src/pages/iam/memberships/MembershipsPage.tsx +++ b/apps/console/src/pages/iam/memberships/MembershipsPage.tsx @@ -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, queryRef); @@ -84,6 +90,16 @@ export function MembershipsPage(props: { {__("Select an organization")}
+ {invitingOrganizations.length > 0 && ( +
+

+ {__("Pending invitations")} +

+ {invitingOrganizations.map(organization => ( + + ))} +
+ )} {initialProfiles.length > 0 && (

diff --git a/apps/console/src/pages/iam/memberships/_components/InvitingOrganizationCard.tsx b/apps/console/src/pages/iam/memberships/_components/InvitingOrganizationCard.tsx new file mode 100644 index 000000000..608383e38 --- /dev/null +++ b/apps/console/src/pages/iam/memberships/_components/InvitingOrganizationCard.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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( + fragment, + fKey, + ); + + return ( + +
+

{organization.name}

+ + + {__("Check your email")} + +
+
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownInvitingItem.tsx b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownInvitingItem.tsx new file mode 100644 index 000000000..f16f8fa6f --- /dev/null +++ b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownInvitingItem.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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( + fragment, + fKey, + ); + + return ( +
+
+ +
+ {organization.name} +
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenu.tsx b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenu.tsx index cf6471759..f8b6546d7 100644 --- a/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenu.tsx +++ b/apps/console/src/pages/iam/organizations/_components/MembershipsDropdownMenu.tsx @@ -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, @@ -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 && ( + <> +
+ {__("Pending invitations")} +
+ {invitingOrganizations.map(organization => ( + + ))} + + + )} {profiles.map(({ node }) => ( ))} diff --git a/e2e/console/inviting_organizations_test.go b/e2e/console/inviting_organizations_test.go new file mode 100644 index 000000000..19773e40d --- /dev/null +++ b/e2e/console/inviting_organizations_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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()) + }) +} diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index c7ef7e350..5c303c6e6 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -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() diff --git a/e2e/internal/testutil/client.go b/e2e/internal/testutil/client.go index 900c2fdc3..411c390ff 100644 --- a/e2e/internal/testutil/client.go +++ b/e2e/internal/testutil/client.go @@ -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 } diff --git a/pkg/coredata/organization.go b/pkg/coredata/organization.go index e593396da..63f53752c 100644 --- a/pkg/coredata/organization.go +++ b/pkg/coredata/organization.go @@ -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, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 3db661301..c155243da 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -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 diff --git a/pkg/server/api/connect/v1/graphql/identity.graphql b/pkg/server/api/connect/v1/graphql/identity.graphql index e8a0bcf94..7382b4848 100644 --- a/pkg/server/api/connect/v1/graphql/identity.graphql +++ b/pkg/server/api/connect/v1/graphql/identity.graphql @@ -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) diff --git a/pkg/server/api/connect/v1/identity_resolvers.go b/pkg/server/api/connect/v1/identity_resolvers.go index 911b8aae2..3e3759b2e 100644 --- a/pkg/server/api/connect/v1/identity_resolvers.go +++ b/pkg/server/api/connect/v1/identity_resolvers.go @@ -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 {