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