approval feed

This commit is contained in:
Fred KISSIE
2026-02-10 03:20:49 +01:00
parent da514ef314
commit 3ba2cb19a9
5 changed files with 201 additions and 87 deletions

View File

@@ -2,23 +2,25 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn";
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
approvalFiltersSchema,
approvalQueries,
type ApprovalItem
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Ban, Check, Loader, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Fragment, useActionState } from "react";
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardHeader } from "./ui/card";
import { InfoPopup } from "./ui/info-popup";
import { Label } from "./ui/label";
import {
Select,
@@ -28,8 +30,6 @@ import {
SelectValue
} from "./ui/select";
import { Separator } from "./ui/separator";
import { InfoPopup } from "./ui/info-popup";
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
export type ApprovalFeedProps = {
orgId: string;
@@ -50,11 +50,17 @@ export function ApprovalFeed({
Object.fromEntries(searchParams.entries())
);
const { data, isFetching, refetch } = useQuery(
approvalQueries.listApprovals(orgId, filters)
);
const {
data,
isFetching,
isLoading,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(approvalQueries.listApprovals(orgId, filters));
const approvals = data?.approvals ?? [];
const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];
// Show empty state if no approvals are enabled for any role
if (!hasApprovalsEnabled) {
@@ -110,13 +116,13 @@ export function ApprovalFeed({
onClick={() => {
refetch();
}}
disabled={isFetching}
disabled={isFetching || isLoading}
className="lg:static gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetching && "animate-spin"
(isFetching || isLoading) && "animate-spin"
)}
/>
{t("refresh")}
@@ -140,13 +146,30 @@ export function ApprovalFeed({
))}
{approvals.length === 0 && (
<li className="flex justify-center items-center p-4 text-muted-foreground">
{t("approvalListEmpty")}
<li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
{isLoading
? t("loadingApprovals")
: t("approvalListEmpty")}
{isLoading && (
<Loader className="size-4 flex-none animate-spin" />
)}
</li>
)}
</ul>
</CardHeader>
</Card>
{hasNextPage && (
<Button
variant="secondary"
className="self-center"
size="lg"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage ? t("loading") : t("approvalLoadMore")}
</Button>
)}
</div>
);
}
@@ -209,19 +232,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
&nbsp;
{approval.type === "user_device" && (
<span className="inline-flex items-center gap-1">
{approval.deviceName ? (
<>
{t("requestingNewDeviceApproval")}:{" "}
{approval.niceId ? (
<Link
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
className="text-primary hover:underline cursor-pointer"
>
{approval.deviceName}
</Link>
) : (
<span>{approval.deviceName}</span>
)}
{approval.deviceName ? (
<>
{t("requestingNewDeviceApproval")}:{" "}
{approval.niceId ? (
<Link
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
className="text-primary hover:underline cursor-pointer"
>
{approval.deviceName}
</Link>
) : (
<span>{approval.deviceName}</span>
)}
{approval.fingerprint && (
<InfoPopup>
<div className="space-y-1 text-sm">
@@ -229,7 +252,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
{t("deviceInformation")}
</div>
<div className="text-muted-foreground whitespace-pre-line">
{formatFingerprintInfo(approval.fingerprint, t)}
{formatFingerprintInfo(
approval.fingerprint,
t
)}
</div>
</div>
</InfoPopup>

View File

@@ -16,11 +16,16 @@ import type {
import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response";
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import {
infiniteQueryOptions,
keepPreviousData,
queryOptions
} from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import z from "zod";
import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import { wait } from "./wait";
export type ProductUpdate = {
link: string | null;
@@ -356,22 +361,50 @@ export const approvalQueries = {
orgId: string,
filters: z.infer<typeof approvalFiltersSchema>
) =>
queryOptions({
infiniteQueryOptions({
queryKey: ["APPROVALS", orgId, filters] as const,
queryFn: async ({ signal, meta }) => {
queryFn: async ({ signal, pageParam, meta }) => {
const sp = new URLSearchParams();
if (filters.approvalState) {
sp.set("approvalState", filters.approvalState);
}
if (pageParam) {
sp.set("cursorPending", pageParam.cursorPending.toString());
sp.set(
"cursorTimestamp",
pageParam.cursorTimestamp.toString()
);
}
const res = await meta!.api.get<
AxiosResponse<{ approvals: ApprovalItem[] }>
AxiosResponse<{
approvals: ApprovalItem[];
pagination: {
total: number;
limit: number;
cursorPending: number | null;
cursorTimestamp: number | null;
};
}>
>(`/org/${orgId}/approvals?${sp.toString()}`, {
signal
});
return res.data.data;
}
},
initialPageParam: null as {
cursorPending: number;
cursorTimestamp: number;
} | null,
placeholderData: keepPreviousData,
getNextPageParam: ({ pagination }) =>
pagination.cursorPending != null &&
pagination.cursorTimestamp != null
? {
cursorPending: pagination.cursorPending,
cursorTimestamp: pagination.cursorTimestamp
}
: null
}),
pendingCount: (orgId: string) =>
queryOptions({