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} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user