paperclip / ui /src /pages /JoinRequestQueue.tsx
cjovs's picture
Deploy Paperclip CN to Hugging Face Space
96e86e5
Raw
History Blame Contribute Delete
11.2 kB
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
export function JoinRequestQueue() {
const { t } = useTranslation();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [status, setStatus] = useState<"pending_approval" | "approved" | "rejected">("pending_approval");
const [requestType, setRequestType] = useState<"all" | "human" | "agent">("all");
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? t("Company", { defaultValue: "Company" }), href: "/dashboard" },
{ label: t("Inbox", { defaultValue: "Inbox" }), href: "/inbox" },
{ label: t("Join Requests", { defaultValue: "Join Requests" }) },
]);
}, [selectedCompany?.name, setBreadcrumbs, t]);
const requestsQuery = useQuery({
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", `${status}:${requestType}`),
queryFn: () =>
accessApi.listJoinRequests(
selectedCompanyId!,
status,
requestType === "all" ? undefined : requestType,
),
enabled: !!selectedCompanyId,
});
const approveMutation = useMutation({
mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!) });
pushToast({ title: t("Join request approved", { defaultValue: "Join request approved" }), tone: "success" });
},
});
const rejectMutation = useMutation({
mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
pushToast({ title: t("Join request rejected", { defaultValue: "Join request rejected" }), tone: "success" });
},
});
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">{t("Select a company to review join requests.", {
defaultValue: "Select a company to review join requests.",
})}</div>;
}
if (requestsQuery.isLoading) {
return <div className="text-sm text-muted-foreground">{t("Loading join requests…", {
defaultValue: "Loading join requests…",
})}</div>;
}
if (requestsQuery.error) {
const message =
requestsQuery.error instanceof ApiError && requestsQuery.error.status === 403
? t("You do not have permission to review join requests for this company.", {
defaultValue: "You do not have permission to review join requests for this company.",
})
: requestsQuery.error instanceof Error
? requestsQuery.error.message
: t("Failed to load join requests.", { defaultValue: "Failed to load join requests." });
return <div className="text-sm text-destructive">{message}</div>;
}
return (
<div className="max-w-6xl space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<UserPlus2 className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{t("Join Request Queue", { defaultValue: "Join Request Queue" })}</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
{t("Review human and agent join requests outside the mixed inbox feed. This queue uses the same approval mutations as the inline inbox cards.", {
defaultValue:
"Review human and agent join requests outside the mixed inbox feed. This queue uses the same approval mutations as the inline inbox cards.",
})}
</p>
</div>
<div className="flex flex-wrap gap-3 rounded-xl border border-border bg-card p-4">
<label className="space-y-2 text-sm">
<span className="font-medium">{t("Status", { defaultValue: "Status" })}</span>
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={status}
onChange={(event) =>
setStatus(event.target.value as "pending_approval" | "approved" | "rejected")
}
>
<option value="pending_approval">{t("Pending approval", { defaultValue: "Pending approval" })}</option>
<option value="approved">{t("Approved", { defaultValue: "Approved" })}</option>
<option value="rejected">{t("Rejected", { defaultValue: "Rejected" })}</option>
</select>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium">{t("Request type", { defaultValue: "Request type" })}</span>
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={requestType}
onChange={(event) =>
setRequestType(event.target.value as "all" | "human" | "agent")
}
>
<option value="all">{t("All", { defaultValue: "All" })}</option>
<option value="human">{t("Human", { defaultValue: "Human" })}</option>
<option value="agent">{t("Agent", { defaultValue: "Agent" })}</option>
</select>
</label>
</div>
<div className="space-y-4">
{(requestsQuery.data ?? []).length === 0 ? (
<div className="rounded-xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground">
{t("No join requests match the current filters.", {
defaultValue: "No join requests match the current filters.",
})}
</div>
) : (
requestsQuery.data!.map((request) => (
<div key={request.id} className="rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={request.status === "pending_approval" ? "secondary" : request.status === "approved" ? "outline" : "destructive"}>
{request.status.replace("_", " ")}
</Badge>
<Badge variant="outline">{request.requestType}</Badge>
{request.adapterType ? <Badge variant="outline">{request.adapterType}</Badge> : null}
</div>
<div>
<div className="text-base font-medium">
{request.requestType === "human"
? request.requesterUser?.name || request.requestEmailSnapshot || request.requestingUserId || t("Unknown human requester", { defaultValue: "Unknown human requester" })
: request.agentName || t("Unknown agent requester", { defaultValue: "Unknown agent requester" })}
</div>
<div className="text-sm text-muted-foreground">
{request.requestType === "human"
? request.requesterUser?.email || request.requestEmailSnapshot || request.requestingUserId
: request.capabilities || request.requestIp}
</div>
</div>
</div>
{request.status === "pending_approval" ? (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => rejectMutation.mutate(request.id)}
disabled={rejectMutation.isPending}
>
{t("Reject", { defaultValue: "Reject" })}
</Button>
<Button
onClick={() => approveMutation.mutate(request.id)}
disabled={approveMutation.isPending}
>
{t("Approve", { defaultValue: "Approve" })}
</Button>
</div>
) : null}
</div>
<div className="mt-4 grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
<div className="rounded-lg border border-border bg-background px-3 py-2">
<div className="text-xs font-medium uppercase tracking-wide">
{t("Invite context", { defaultValue: "Invite context" })}
</div>
<div className="mt-2">
{request.invite
? t("{{type}} join invite{{suffix}}", {
defaultValue: "{{type}} join invite{{suffix}}",
type: request.invite.allowedJoinTypes,
suffix: request.invite.humanRole ? ` • ${t("default role {{role}}", {
defaultValue: "default role {{role}}",
role: request.invite.humanRole,
})}` : "",
})
: t("Invite metadata unavailable", { defaultValue: "Invite metadata unavailable" })}
</div>
{request.invite?.inviteMessage ? (
<div className="mt-2 text-foreground">{request.invite.inviteMessage}</div>
) : null}
</div>
<div className="rounded-lg border border-border bg-background px-3 py-2">
<div className="text-xs font-medium uppercase tracking-wide">
{t("Request details", { defaultValue: "Request details" })}
</div>
<div className="mt-2">
{t("Submitted {{date}}", {
defaultValue: "Submitted {{date}}",
date: new Date(request.createdAt).toLocaleString(),
})}
</div>
<div>
{t("Source IP {{ip}}", {
defaultValue: "Source IP {{ip}}",
ip: request.requestIp,
})}
</div>
{request.requestType === "agent" && request.capabilities ? <div>{request.capabilities}</div> : null}
</div>
</div>
</div>
))
)}
</div>
</div>
);
}