Files
zerobyte/app/client/modules/settings/components/pending-invitations-section.tsx

162 lines
5.7 KiB
TypeScript

import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { MailCheck, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { getUserSsoInvitationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { startInvitationSsoVerification, type Options } from "~/client/api-client/sdk.gen";
import type { GetUserSsoInvitationsResponse, StartInvitationSsoVerificationData } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { authClient } from "~/client/lib/auth-client";
import { useTimeFormat } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
type UserInvitation = GetUserSsoInvitationsResponse[number];
type VerifyInvitationVariables = Options<StartInvitationSsoVerificationData>;
type Props = {
initialInvitations: GetUserSsoInvitationsResponse;
userEmail?: string | null;
};
export function PendingInvitationsSection({ initialInvitations, userEmail }: Props) {
const { formatDateTime } = useTimeFormat();
const [selectedInvitationProviders, setSelectedInvitationProviders] = useState<Record<string, string>>({});
const { data: pendingInvitations } = useSuspenseQuery({
...getUserSsoInvitationsOptions(),
initialData: initialInvitations,
});
const verifyInvitation = useMutation({
mutationFn: async (variables: VerifyInvitationVariables) => {
await startInvitationSsoVerification({ ...variables, throwOnError: true });
const providerId = variables.body.providerId;
const { data, error } = await authClient.signIn.sso({
providerId,
callbackURL: "/settings",
errorCallbackURL: "/api/v1/auth/login-error",
loginHint: userEmail ?? undefined,
});
if (error) {
throw new Error(error.message);
}
if (!data?.url) {
throw new Error("SSO verification did not return a redirect URL");
}
return data.url;
},
onSuccess: (url) => {
window.location.href = url;
},
onError: (error) => {
toast.error("Failed to start SSO verification", { description: parseError(error)?.message });
},
});
const getSelectedInvitationProvider = (invitation: UserInvitation) => {
return selectedInvitationProviders[invitation.id] ?? invitation.ssoProviders[0]?.providerId ?? "";
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<MailCheck className="size-5" />
Pending Invitations
</CardTitle>
<CardDescription className="mt-1.5">Organization invitations sent to this account</CardDescription>
</div>
<CardContent className="p-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingInvitations.map((invitation) => {
const providerId = getSelectedInvitationProvider(invitation);
const hasMultipleProviders = invitation.ssoProviders.length > 1;
const isVerifying =
verifyInvitation.isPending &&
verifyInvitation.variables?.path.invitationId === invitation.id;
return (
<TableRow key={invitation.id}>
<TableCell className="font-medium">{invitation.organizationName}</TableCell>
<TableCell className="uppercase">{invitation.role}</TableCell>
<TableCell>{formatDateTime(new Date(invitation.expiresAt))}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{hasMultipleProviders && (
<Select
value={providerId}
onValueChange={(nextProviderId) =>
setSelectedInvitationProviders((current) => ({
...current,
[invitation.id]: nextProviderId,
}))
}
>
<SelectTrigger className="h-9 w-40" aria-label="SSO provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
{invitation.ssoProviders.map((provider) => (
<SelectItem
key={provider.providerId}
value={provider.providerId}
>
{provider.providerId}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
type="button"
variant="outline"
size="sm"
title="Verify with SSO"
loading={isVerifying}
disabled={!providerId || verifyInvitation.isPending}
onClick={() =>
verifyInvitation.mutate({
path: { invitationId: invitation.id },
body: { providerId },
})
}
>
<ShieldCheck size={16} className="mr-2" />
Verify
</Button>
</div>
</TableCell>
</TableRow>
);
})}
<TableRow className={cn({ hidden: pendingInvitations.length > 0 })}>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground">
No pending invitations.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</>
);
}