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

@@ -459,6 +459,8 @@
"filterByApprovalState": "Filter By Approval State", "filterByApprovalState": "Filter By Approval State",
"approvalListEmpty": "No approvals", "approvalListEmpty": "No approvals",
"approvalState": "Approval State", "approvalState": "Approval State",
"approvalLoadMore": "Load more",
"loadingApprovals": "Loading Approvals",
"approve": "Approve", "approve": "Approve",
"approved": "Approved", "approved": "Approved",
"denied": "Denied", "denied": "Denied",

View File

@@ -30,7 +30,7 @@ import {
currentFingerprint, currentFingerprint,
type Approval type Approval
} from "@server/db"; } from "@server/db";
import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
@@ -39,18 +39,26 @@ const paramsSchema = z.strictObject({
}); });
const querySchema = z.strictObject({ const querySchema = z.strictObject({
limit: z limit: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20),
.pipe(z.int().nonnegative()), cursorPending: z.coerce // pending cursor
offset: z .number<string>()
.string() .int()
.max(1) // 0 means non pending
.min(0) // 1 means pending
.optional() .optional()
.default("0") .catch(undefined),
.transform(Number) cursorTimestamp: z.coerce
.pipe(z.int().nonnegative()), .number<string>()
.int()
.positive()
.optional()
.catch(undefined),
approvalState: z approvalState: z
.enum(["pending", "approved", "denied", "all"]) .enum(["pending", "approved", "denied", "all"])
.optional() .optional()
@@ -63,13 +71,21 @@ const querySchema = z.strictObject({
.pipe(z.number().int().positive().optional()) .pipe(z.number().int().positive().optional())
}); });
async function queryApprovals( async function queryApprovals({
orgId: string, orgId,
limit: number, limit,
offset: number, approvalState,
approvalState: z.infer<typeof querySchema>["approvalState"], cursorPending,
clientId?: number cursorTimestamp,
) { clientId
}: {
orgId: string;
limit: number;
approvalState: z.infer<typeof querySchema>["approvalState"];
cursorPending?: number;
cursorTimestamp?: number;
clientId?: number;
}) {
let state: Array<Approval["decision"]> = []; let state: Array<Approval["decision"]> = [];
switch (approvalState) { switch (approvalState) {
case "pending": case "pending":
@@ -85,6 +101,26 @@ async function queryApprovals(
state = ["approved", "denied", "pending"]; state = ["approved", "denied", "pending"];
} }
const conditions = [
eq(approvals.orgId, orgId),
sql`${approvals.decision} in ${state}`
];
if (clientId) {
conditions.push(eq(approvals.clientId, clientId));
}
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
if (cursorPending != null && cursorTimestamp != null) {
// https://stackoverflow.com/a/79720298/10322846
// composite cursor, next data means (pending, timestamp) <= cursor
conditions.push(
lte(pendingSortKey, cursorPending),
lte(approvals.timestamp, cursorTimestamp)
);
}
const res = await db const res = await db
.select({ .select({
approvalId: approvals.approvalId, approvalId: approvals.approvalId,
@@ -107,7 +143,8 @@ async function queryApprovals(
fingerprintArch: currentFingerprint.arch, fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username, fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname fingerprintHostname: currentFingerprint.hostname,
timestamp: approvals.timestamp
}) })
.from(approvals) .from(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId))) .innerJoin(users, and(eq(approvals.userId, users.userId)))
@@ -120,22 +157,12 @@ async function queryApprovals(
) )
.leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
.where( .where(and(...conditions))
and( .orderBy(desc(pendingSortKey), desc(approvals.timestamp))
eq(approvals.orgId, orgId), .limit(limit + 1); // the `+1` is used for the cursor
sql`${approvals.decision} in ${state}`,
...(clientId ? [eq(approvals.clientId, clientId)] : [])
)
)
.orderBy(
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
desc(approvals.timestamp)
)
.limit(limit)
.offset(offset);
// Process results to format device names and build fingerprint objects // Process results to format device names and build fingerprint objects
return res.map((approval) => { const approvalsList = res.slice(0, limit).map((approval) => {
const model = approval.deviceModel || null; const model = approval.deviceModel || null;
const deviceName = approval.clientName const deviceName = approval.clientName
? getUserDeviceName(model, approval.clientName) ? getUserDeviceName(model, approval.clientName)
@@ -154,15 +181,15 @@ async function queryApprovals(
const fingerprint = hasFingerprintData const fingerprint = hasFingerprintData
? { ? {
platform: approval.fingerprintPlatform || null, platform: approval.fingerprintPlatform ?? null,
osVersion: approval.fingerprintOsVersion || null, osVersion: approval.fingerprintOsVersion ?? null,
kernelVersion: approval.fingerprintKernelVersion || null, kernelVersion: approval.fingerprintKernelVersion ?? null,
arch: approval.fingerprintArch || null, arch: approval.fingerprintArch ?? null,
deviceModel: approval.deviceModel || null, deviceModel: approval.deviceModel ?? null,
serialNumber: approval.fingerprintSerialNumber || null, serialNumber: approval.fingerprintSerialNumber ?? null,
username: approval.fingerprintUsername || null, username: approval.fingerprintUsername ?? null,
hostname: approval.fingerprintHostname || null hostname: approval.fingerprintHostname ?? null
} }
: null; : null;
const { const {
@@ -185,11 +212,30 @@ async function queryApprovals(
niceId: approval.niceId || null niceId: approval.niceId || null
}; };
}); });
let nextCursorPending: number | null = null;
let nextCursorTimestamp: number | null = null;
if (res.length > limit) {
const lastItem = res[limit];
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
nextCursorTimestamp = lastItem.timestamp;
}
return {
approvalsList,
nextCursorPending,
nextCursorTimestamp
};
} }
export type ListApprovalsResponse = { export type ListApprovalsResponse = {
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>; approvals: NonNullable<
pagination: { total: number; limit: number; offset: number }; Awaited<ReturnType<typeof queryApprovals>>
>["approvalsList"];
pagination: {
total: number;
limit: number;
cursorPending: number | null;
cursorTimestamp: number | null;
};
}; };
export async function listApprovals( export async function listApprovals(
@@ -217,7 +263,13 @@ export async function listApprovals(
) )
); );
} }
const { limit, offset, approvalState, clientId } = parsedQuery.data; const {
limit,
cursorPending,
cursorTimestamp,
approvalState,
clientId
} = parsedQuery.data;
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
@@ -234,13 +286,15 @@ export async function listApprovals(
} }
} }
const approvalsList = await queryApprovals( const { approvalsList, nextCursorPending, nextCursorTimestamp } =
orgId.toString(), await queryApprovals({
limit, orgId: orgId.toString(),
offset, limit,
approvalState, cursorPending,
clientId cursorTimestamp,
); approvalState,
clientId
});
const [{ count }] = await db const [{ count }] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -252,7 +306,8 @@ export async function listApprovals(
pagination: { pagination: {
total: count, total: count,
limit, limit,
offset cursorPending: nextCursorPending,
cursorTimestamp: nextCursorTimestamp
} }
}, },
success: true, success: true,

View File

@@ -6,7 +6,6 @@ import {
olms, olms,
orgs, orgs,
roleClients, roleClients,
sites,
userClients, userClients,
users users
} from "@server/db"; } from "@server/db";
@@ -25,7 +24,6 @@ import {
inArray, inArray,
isNotNull, isNotNull,
isNull, isNull,
not,
or, or,
sql, sql,
type SQL type SQL

View File

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

View File

@@ -16,11 +16,16 @@ import type {
import type { ListTargetsResponse } from "@server/routers/target"; import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user"; import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response"; 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 type { AxiosResponse } from "axios";
import z from "zod"; import z from "zod";
import { remote } from "./api"; import { remote } from "./api";
import { durationToMs } from "./durationToMs"; import { durationToMs } from "./durationToMs";
import { wait } from "./wait";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; link: string | null;
@@ -356,22 +361,50 @@ export const approvalQueries = {
orgId: string, orgId: string,
filters: z.infer<typeof approvalFiltersSchema> filters: z.infer<typeof approvalFiltersSchema>
) => ) =>
queryOptions({ infiniteQueryOptions({
queryKey: ["APPROVALS", orgId, filters] as const, queryKey: ["APPROVALS", orgId, filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, pageParam, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (filters.approvalState) { if (filters.approvalState) {
sp.set("approvalState", 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< 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()}`, { >(`/org/${orgId}/approvals?${sp.toString()}`, {
signal signal
}); });
return res.data.data; 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) => pendingCount: (orgId: string) =>
queryOptions({ queryOptions({