Refactor invitation system

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-10 01:11:09 +02:00
parent 0f96b8518f
commit 165eb267f2
39 changed files with 2730 additions and 1194 deletions

View File

@@ -11,6 +11,8 @@ import {
IconPlusLarge,
} from "@probo/ui";
import { usePageTitle } from "@probo/hooks";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { formatDate } from "@probo/helpers";
const OrganizationsPageQuery = graphql`
query OrganizationsPageQuery {
@@ -25,6 +27,34 @@ const OrganizationsPageQuery = graphql`
}
}
}
invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {onlyPending: true}) @connection(key: "OrganizationsPage_invitations") {
__id
edges {
node {
id
email
fullName
role
expiresAt
acceptedAt
createdAt
organization {
id
name
}
}
}
}
}
}
`;
const acceptInvitationMutation = graphql`
mutation OrganizationsPage_AcceptInvitationMutation($input: AcceptInvitationInput!) {
acceptInvitation(input: $input) {
invitation {
id
}
}
}
`;
@@ -41,15 +71,40 @@ export default function OrganizationsPage() {
(edge) => edge.node
);
const pendingInvitations = data.viewer.invitations.edges.map(
(edge) => edge.node
);
const [acceptInvitation, isAccepting] = useMutationWithToasts(
acceptInvitationMutation,
{
successMessage: __("Invitation accepted successfully"),
errorMessage: __("Failed to accept invitation"),
}
);
const handleAcceptInvitation = (invitationId: string, organizationId: string) => {
acceptInvitation({
variables: {
input: {
invitationId,
},
},
onSuccess: () => {
navigate(`/organizations/${organizationId}`);
},
});
};
usePageTitle(__("Select an organization"));
useEffect(() => {
if (organizations.length === 1) {
if (organizations.length === 1 && pendingInvitations.length === 0) {
navigate(`/organizations/${organizations[0].id}`);
} else if (organizations.length === 0) {
} else if (organizations.length === 0 && pendingInvitations.length === 0) {
navigate("/organizations/new");
}
}, [organizations]);
}, [organizations, pendingInvitations]);
return (
<>
@@ -58,12 +113,36 @@ export default function OrganizationsPage() {
{__("Select an organization")}
</h1>
<div className="space-y-4 w-full">
{organizations.map((organization) => (
<OrganizationCard
key={organization.id}
organization={organization}
/>
))}
{pendingInvitations.length > 0 && (
<div className="space-y-3">
<h2 className="text-xl font-semibold">
{__("Pending invitations")}
</h2>
{pendingInvitations.map((invitation) => (
<InvitationCard
key={invitation.id}
invitation={invitation}
onAccept={handleAcceptInvitation}
isAccepting={isAccepting}
/>
))}
</div>
)}
{organizations.length > 0 && (
<div className="space-y-3">
{pendingInvitations.length > 0 && (
<h2 className="text-xl font-semibold">
{__("Your organizations")}
</h2>
)}
{organizations.map((organization) => (
<OrganizationCard
key={organization.id}
organization={organization}
/>
))}
</div>
)}
<Card padded>
<h2 className="text-xl font-semibold mb-1">
{__("Create an organization")}
@@ -86,6 +165,51 @@ export default function OrganizationsPage() {
);
}
type InvitationCardProps = {
invitation: {
id: string;
email: string;
fullName: string;
role: string;
expiresAt: string;
createdAt: string;
organization: {
id: string;
name: string;
};
};
onAccept: (invitationId: string, organizationId: string) => void;
isAccepting: boolean;
};
function InvitationCard({ invitation, onAccept, isAccepting }: InvitationCardProps) {
const { __ } = useTranslate();
return (
<Card padded className="w-full">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<h3 className="text-lg font-semibold">
{invitation.organization.name}
</h3>
<p className="text-sm text-txt-secondary">
{__("Role")}: <span className="font-medium">{invitation.role}</span>
</p>
<p className="text-xs text-txt-tertiary">
{__("Invited on")} {formatDate(invitation.createdAt)}
</p>
</div>
<Button
onClick={() => onAccept(invitation.id, invitation.organization.id)}
disabled={isAccepting}
>
{isAccepting ? __("Accepting...") : __("Accept invitation")}
</Button>
</div>
</Card>
);
}
type OrganizationCardProps = {
organization: {
id: string;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<84d0e43eced78862d1c82603e9c332e8>>
* @generated SignedSource<<c25588b7506704dd23d9c8be8672f9ef>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,6 +12,24 @@ import { ConcreteRequest } from 'relay-runtime';
export type OrganizationsPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationsPageQuery$data = {
readonly viewer: {
readonly invitations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly acceptedAt: any | null | undefined;
readonly createdAt: any;
readonly email: string;
readonly expiresAt: any;
readonly fullName: string;
readonly id: string;
readonly organization: {
readonly id: string;
readonly name: string;
};
readonly role: string;
};
}>;
};
readonly organizations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
@@ -45,7 +63,65 @@ v1 = {
"name": "id",
"storageKey": null
},
v2 = [
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
v6 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v7 = [
{
"alias": null,
"args": null,
@@ -63,13 +139,7 @@ v2 = [
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
(v2/*: any*/),
{
"alias": null,
"args": null,
@@ -77,71 +147,129 @@ v2 = [
"name": "logoUrl",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
v8 = {
"kind": "Literal",
"name": "filter",
"value": {
"onlyPending": true
}
},
v9 = {
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "DESC",
"field": "CREATED_AT"
}
},
v10 = [
{
"alias": null,
"args": null,
"concreteType": "InvitationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"name": "email",
"storageKey": null
}
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "acceptedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/)
],
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
(v4/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
(v5/*: any*/),
(v6/*: any*/)
],
v3 = [
{
"kind": "Literal",
"name": "first",
"value": 1000
},
v11 = {
"kind": "Literal",
"name": "first",
"value": 1000
},
v12 = [
(v11/*: any*/),
(v0/*: any*/)
],
v13 = [
(v8/*: any*/),
(v11/*: any*/),
(v9/*: any*/)
];
return {
"fragment": {
@@ -167,8 +295,21 @@ return {
"kind": "LinkedField",
"name": "__OrganizationsPage_organizations_connection",
"plural": false,
"selections": (v2/*: any*/),
"selections": (v7/*: any*/),
"storageKey": "__OrganizationsPage_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
},
{
"alias": "invitations",
"args": [
(v8/*: any*/),
(v9/*: any*/)
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "__OrganizationsPage_invitations_connection",
"plural": false,
"selections": (v10/*: any*/),
"storageKey": "__OrganizationsPage_invitations_connection(filter:{\"onlyPending\":true},orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
}
],
"storageKey": null
@@ -193,17 +334,17 @@ return {
"selections": [
{
"alias": null,
"args": (v3/*: any*/),
"args": (v12/*: any*/),
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": (v2/*: any*/),
"selections": (v7/*: any*/),
"storageKey": "organizations(first:1000,orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
},
{
"alias": null,
"args": (v3/*: any*/),
"args": (v12/*: any*/),
"filters": [
"orderBy"
],
@@ -212,6 +353,28 @@ return {
"kind": "LinkedHandle",
"name": "organizations"
},
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": (v10/*: any*/),
"storageKey": "invitations(filter:{\"onlyPending\":true},first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "OrganizationsPage_invitations",
"kind": "LinkedHandle",
"name": "invitations"
},
(v1/*: any*/)
],
"storageKey": null
@@ -219,7 +382,7 @@ return {
]
},
"params": {
"cacheID": "1735764e6816660969c5f96922320ac5",
"cacheID": "5675b3eb7810ef04bf531d7bf988c86c",
"id": null,
"metadata": {
"connection": [
@@ -231,16 +394,25 @@ return {
"viewer",
"organizations"
]
},
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"viewer",
"invitations"
]
}
]
},
"name": "OrganizationsPageQuery",
"operationKind": "query",
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n"
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {onlyPending: true}) {\n edges {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n organization {\n id\n name\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "6fde39384e4678f88f17c34bbd30e684";
(node as any).hash = "a548f9c3a434e12c079fb2ff651822d2";
export default node;

View File

@@ -0,0 +1,105 @@
/**
* @generated SignedSource<<b883cd0523e827b2c71b89e1d249d6dd>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type AcceptInvitationInput = {
invitationId: string;
};
export type OrganizationsPage_AcceptInvitationMutation$variables = {
input: AcceptInvitationInput;
};
export type OrganizationsPage_AcceptInvitationMutation$data = {
readonly acceptInvitation: {
readonly invitation: {
readonly id: string;
};
};
};
export type OrganizationsPage_AcceptInvitationMutation = {
response: OrganizationsPage_AcceptInvitationMutation$data;
variables: OrganizationsPage_AcceptInvitationMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "AcceptInvitationPayload",
"kind": "LinkedField",
"name": "acceptInvitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "invitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "OrganizationsPage_AcceptInvitationMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "OrganizationsPage_AcceptInvitationMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "cc4a442037edaa624948b5be0c009823",
"id": null,
"metadata": {},
"name": "OrganizationsPage_AcceptInvitationMutation",
"operationKind": "mutation",
"text": "mutation OrganizationsPage_AcceptInvitationMutation(\n $input: AcceptInvitationInput!\n) {\n acceptInvitation(input: $input) {\n invitation {\n id\n }\n }\n}\n"
}
};
})();
(node as any).hash = "190213ab6fdc068343a270b4fa94e160";
export default node;

View File

@@ -1,35 +1,49 @@
import { Link, useNavigate } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders";
import { useEffect } from "react";
const schema = z.object({
fullName: z.string().min(2),
password: z.string().min(8),
});
export default function ConfirmInvitationPage() {
export default function SignupFromInvitationPage() {
const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast();
const { register, handleSubmit, formState } = useFormWithSchema(
const [searchParams] = useSearchParams();
const { register, handleSubmit, formState, reset } = useFormWithSchema(
schema,
{
defaultValues: {
fullName: "",
password: "",
},
}
);
useEffect(() => {
const fullNameFromParams = searchParams.get("fullName") || "";
if (fullNameFromParams) {
reset({
fullName: fullNameFromParams,
password: "",
});
}
}, [searchParams, reset]);
const onSubmit = handleSubmit(async (data) => {
const searchParams = new URLSearchParams(location.search);
const token = searchParams.get("token");
if (!token) {
toast({
title: __("Confirmation failed"),
title: __("Signup failed"),
description: __("Invalid or missing invitation token"),
variant: "error",
});
@@ -37,7 +51,7 @@ export default function ConfirmInvitationPage() {
}
const response = await fetch(
buildEndpoint("/api/console/v1/auth/invitation"),
buildEndpoint("/api/console/v1/auth/signup-from-invitation"),
{
method: "POST",
headers: {
@@ -47,16 +61,16 @@ export default function ConfirmInvitationPage() {
body: JSON.stringify({
token: token,
password: data.password,
fullName: data.fullName,
}),
}
);
// Registration failed
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
toast({
title: __("Confirmation failed"),
description: errorData.message || __("Confirmation failed"),
title: __("Signup failed"),
description: errorData.message || __("Signup failed"),
variant: "error",
});
return;
@@ -64,24 +78,33 @@ export default function ConfirmInvitationPage() {
toast({
title: __("Success"),
description: __("Invitation confirmed successfully"),
description: __("Account created successfully. Please accept your invitation to join the organization."),
variant: "success",
});
navigate("/", { replace: true });
});
usePageTitle(__("Confirm invitation"));
usePageTitle(__("Create your account"));
return (
<div className="space-y-6 w-full max-w-md mx-auto">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{__("Confirm invitation")}</h1>
<h1 className="text-3xl font-bold">{__("Create your account")}</h1>
<p className="text-txt-tertiary">
{__("Enter your information to confirm your invitation")}
{__("Set your password to join the organization")}
</p>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<Field
label={__("Full Name")}
type="text"
placeholder={__("John Doe")}
{...register("fullName")}
required
error={formState.errors.fullName?.message}
/>
<Field
label={__("Password")}
type="password"
@@ -93,8 +116,8 @@ export default function ConfirmInvitationPage() {
<Button type="submit" className="w-full" disabled={formState.isLoading}>
{formState.isLoading
? __("Confirming invitation...")
: __("Confirm invitation")}
? __("Creating account...")
: __("Create account")}
</Button>
</form>

View File

@@ -50,13 +50,13 @@ import { sprintf } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { z } from "zod";
import type { NodeOf } from "/types";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
import { useNavigate } from "react-router";
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
import { CustomDomainManager } from "/components/customDomains/CustomDomainManager";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
const organizationSchema = z.object({
name: z.string().min(1, "Organization name is required"),
@@ -220,6 +220,7 @@ const deleteHorizontalLogoMutation = graphql`
export default function SettingsPage({ queryRef }: Props) {
const { __ } = useTranslate();
const navigate = useNavigate();
const organizationId = useOrganizationId();
const organizationKey = usePreloadedQuery(
organizationViewQuery,
queryRef
@@ -240,6 +241,14 @@ export default function SettingsPage({ queryRef }: Props) {
organizationKey as SettingsPageInvitationsFragment$key
);
const refetchMemberships = () => {
membershipsPagination.refetch({}, { fetchPolicy: 'network-only' });
};
const refetchInvitations = () => {
invitationsPagination.refetch({}, { fetchPolicy: 'network-only' });
};
const [updateOrganization] = useMutation(updateOrganizationMutation);
const [deleteHorizontalLogo, isDeletingHorizontalLogo] = useMutationWithToasts(
deleteHorizontalLogoMutation,
@@ -253,24 +262,6 @@ export default function SettingsPage({ queryRef }: Props) {
const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || [];
const [activeTab, setActiveTab] = useState<"memberships" | "invitations">("memberships");
const refetchMemberships = ({ order }: { order: { direction: string; field: string } }) => {
membershipsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
}
});
};
const refetchInvitations = ({ order }: { order: { direction: string; field: string } }) => {
invitationsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
}
});
};
const { formState, handleSubmit, register, reset } = useFormWithSchema(
organizationSchema,
{
@@ -572,7 +563,10 @@ export default function SettingsPage({ queryRef }: Props) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
<InviteUserDialog connectionId={invitationsPagination.data.invitations?.__id}>
<InviteUserDialog
connectionId={invitationsPagination.data.invitations?.__id}
onRefetch={refetchInvitations}
>
<Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog>
</div>
@@ -603,7 +597,14 @@ export default function SettingsPage({ queryRef }: Props) {
{activeTab === "memberships" && (
<SortableTable
{...membershipsPagination}
refetch={refetchMemberships}
refetch={({ order }: { order: { direction: string; field: string } }) => {
membershipsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
}
});
}}
>
<Thead>
<Tr>
@@ -623,7 +624,13 @@ export default function SettingsPage({ queryRef }: Props) {
</Tr>
) : (
memberships.map((membership) => (
<MembershipRow key={membership.id} membership={membership} />
<MembershipRow
key={membership.id}
membership={membership}
connectionId={membershipsPagination.data.memberships?.__id}
organizationId={organizationId}
onRefetch={refetchMemberships}
/>
))
)}
</Tbody>
@@ -633,7 +640,14 @@ export default function SettingsPage({ queryRef }: Props) {
{activeTab === "invitations" && (
<SortableTable
{...invitationsPagination}
refetch={refetchInvitations}
refetch={({ order }: { order: { direction: string; field: string } }) => {
invitationsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
}
});
}}
>
<Thead>
<Tr>
@@ -659,6 +673,8 @@ export default function SettingsPage({ queryRef }: Props) {
key={invitation.id}
invitation={invitation}
connectionId={invitationsPagination.data.invitations?.__id}
organizationId={organizationId}
onRefetch={refetchInvitations}
/>
))
)}
@@ -794,9 +810,12 @@ function Connectors(props: {
}
const removeMemberMutation = graphql`
mutation SettingsPage_RemoveMemberMutation($input: RemoveMemberInput!) {
mutation SettingsPage_RemoveMemberMutation(
$input: RemoveMemberInput!
$connections: [ID!]!
) {
removeMember(input: $input) {
success
deletedMemberId @deleteEdge(connections: $connections)
}
}
`;
@@ -804,16 +823,16 @@ const removeMemberMutation = graphql`
function InvitationRow(props: {
invitation: NodeOf<SettingsPageInvitationsFragment$data["invitations"]>;
connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) {
const { __ } = useTranslate();
const confirm = useConfirm();
const [deleteInvitation, isDeleting] = useMutationWithToasts(
deleteInvitationMutation,
{
successMessage: sprintf(
__("Invitation for %s deleted successfully"),
props.invitation.fullName
),
successMessage: __("Invitation deleted successfully"),
errorMessage: __("Failed to delete invitation"),
}
);
@@ -830,6 +849,9 @@ function InvitationRow(props: {
},
connections: props.connectionId ? [props.connectionId] : [],
},
onCompleted: () => {
props.onRefetch();
},
});
},
{
@@ -885,15 +907,16 @@ function InvitationRow(props: {
);
}
function MembershipRow(props: { membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]> }) {
function MembershipRow(props: {
membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]>;
connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
successMessage: sprintf(
__("Member %s removed successfully"),
props.membership.fullName
),
errorMessage: sprintf(__("Failed to remove member %s"), props.membership.fullName),
successMessage: __("Member removed successfully"),
errorMessage: __("Failed to remove member"),
});
const confirm = useConfirm();
const [isRemoved, setIsRemoved] = useState(false);
@@ -909,11 +932,13 @@ function MembershipRow(props: { membership: NodeOf<SettingsPageMembershipsFragme
variables: {
input: {
memberId: props.membership.id,
organizationId: organizationId,
organizationId: props.organizationId,
},
connections: props.connectionId ? [props.connectionId] : [],
},
onSuccess: () => {
onCompleted: () => {
setIsRemoved(true);
props.onRefetch();
},
});
},

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4f099d1ee6b4635ca8129eca77d3f7e8>>
* @generated SignedSource<<09561c2c29459fc840773b414af6083a>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,6 +9,7 @@
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
import { FragmentRefs } from "relay-runtime";
export type SettingsPageInvitationsFragment$data = {
readonly id: string;
@@ -23,6 +24,7 @@ export type SettingsPageInvitationsFragment$data = {
readonly fullName: string;
readonly id: string;
readonly role: string;
readonly status: InvitationStatus;
};
}>;
readonly totalCount: number;
@@ -171,6 +173,13 @@ return {
"name": "role",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -273,6 +282,6 @@ return {
};
})();
(node as any).hash = "d971d93653991efde284ed2b87068698";
(node as any).hash = "f9a1ec38579cea21312ba0a20bb7394a";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<994eb713978ecef3329fd9873e4c744c>>
* @generated SignedSource<<d210161405099a17aa25d06ca4563634>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,11 +14,12 @@ export type RemoveMemberInput = {
organizationId: string;
};
export type SettingsPage_RemoveMemberMutation$variables = {
connections: ReadonlyArray<string>;
input: RemoveMemberInput;
};
export type SettingsPage_RemoveMemberMutation$data = {
readonly removeMember: {
readonly success: boolean;
readonly deletedMemberId: string;
};
};
export type SettingsPage_RemoveMemberMutation = {
@@ -27,67 +28,106 @@ export type SettingsPage_RemoveMemberMutation = {
};
const node: ConcreteRequest = (function(){
var v0 = [
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "RemoveMemberPayload",
"kind": "LinkedField",
"name": "removeMember",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedMemberId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/),
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "RemoveMemberPayload",
"kind": "LinkedField",
"name": "removeMember",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/)
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "RemoveMemberPayload",
"kind": "LinkedField",
"name": "removeMember",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedMemberId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "97e29046871ce8aab01abf98a62236fc",
"cacheID": "e2dd0f4d7327ce3bc97754c85d3f700d",
"id": null,
"metadata": {},
"name": "SettingsPage_RemoveMemberMutation",
"operationKind": "mutation",
"text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n success\n }\n}\n"
"text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n deletedMemberId\n }\n}\n"
}
};
})();
(node as any).hash = "f61071a0fb6f6554e56e79b6b04bc135";
(node as any).hash = "9909a8b95f8d8621ffdf02da34ec8da2";
export default node;