approval list UI

This commit is contained in:
Fred KISSIE
2026-01-10 02:37:50 +01:00
parent 19c3efc9e9
commit 262376aa75
7 changed files with 230 additions and 12 deletions

View File

@@ -452,6 +452,12 @@
"selectDuration": "Select duration", "selectDuration": "Select duration",
"selectResource": "Select Resource", "selectResource": "Select Resource",
"filterByResource": "Filter By Resource", "filterByResource": "Filter By Resource",
"selectApprovalState": "Select Approval State",
"filterByApprovalState": "Filter By Approval State",
"approve": "Approve",
"deny": "Deny",
"viewDetails": "View Details",
"requestingNewDeviceApproval": "is requesting new device",
"resetFilters": "Reset Filters", "resetFilters": "Reset Filters",
"totalBlocked": "Requests Blocked By Pangolin", "totalBlocked": "Requests Blocked By Pangolin",
"totalRequests": "Total Requests", "totalRequests": "Total Requests",

View File

@@ -308,10 +308,12 @@ export const approvals = pgTable("approvals", {
clientId: integer("clientId").references(() => clients.clientId, { clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade" onDelete: "cascade"
}), // clients reference user devices (in this case) }), // clients reference user devices (in this case)
userId: varchar("userId").references(() => users.userId, { userId: varchar("userId")
// optionally tied to a user and in this case delete when the user deletes .references(() => users.userId, {
onDelete: "cascade" // optionally tied to a user and in this case delete when the user deletes
}), onDelete: "cascade"
})
.notNull(),
decision: varchar("decision") decision: varchar("decision")
.$type<"approved" | "denied" | "pending">() .$type<"approved" | "denied" | "pending">()
.default("pending") .default("pending")

View File

@@ -50,13 +50,16 @@ async function queryApprovals(orgId: string, limit: number, offset: number) {
approvalId: approvals.id, approvalId: approvals.id,
orgId: approvals.orgId, orgId: approvals.orgId,
clientId: approvals.clientId, clientId: approvals.clientId,
userId: users.userId,
username: users.username,
name: users.name,
decision: approvals.decision, decision: approvals.decision,
type: approvals.type type: approvals.type,
user: {
name: users.name,
userId: users.userId,
username: users.username
}
}) })
.from(approvals) .from(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId)))
.leftJoin( .leftJoin(
clients, clients,
and( and(
@@ -64,7 +67,6 @@ async function queryApprovals(orgId: string, limit: number, offset: number) {
not(isNull(clients.userId)) // only user devices not(isNull(clients.userId)) // only user devices
) )
) )
.leftJoin(users, and(eq(approvals.userId, users.userId)))
.where(eq(approvals.orgId, orgId)) .where(eq(approvals.orgId, orgId))
.orderBy(desc(approvals.timestamp)) .orderBy(desc(approvals.timestamp))
.limit(limit) .limit(limit)

View File

@@ -1,3 +1,4 @@
import { ApprovalFeed } from "@app/components/ApprovalFeed";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
@@ -42,7 +43,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
description={t("accessApprovalsDescription")} description={t("accessApprovalsDescription")}
/> />
<OrgProvider org={org}> <OrgProvider org={org}>
<h1>Orgs</h1> <div className="container mx-auto max-w-12xl">
<ApprovalFeed orgId={params.orgId} />
</div>
</OrgProvider> </OrgProvider>
</> </>
); );

View File

@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "../../../../components/SitesTable"; import SitesTable, { SiteRow } from "@app/components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner"; import SitesBanner from "@app/components/SitesBanner";
import SitesSplashCard from "../../../../components/SitesSplashCard";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
type SitesPageProps = { type SitesPageProps = {

View File

@@ -0,0 +1,183 @@
"use client";
import { useTranslations } from "next-intl";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { Card, CardHeader } from "./ui/card";
import { Button } from "./ui/button";
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
import { cn } from "@app/lib/cn";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { approvalFiltersSchema, approvalQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { Fragment } from "react";
import { Separator } from "./ui/separator";
import type { ListApprovalsResponse } from "@server/private/routers/approvals";
import Link from "next/link";
export type ApprovalFeedProps = {
orgId: string;
};
export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
const searchParams = useSearchParams();
const path = usePathname();
const t = useTranslations();
const router = useRouter();
const filters = approvalFiltersSchema.parse(
Object.fromEntries(searchParams.entries())
);
const { data, isFetching, refetch } = useQuery(
approvalQueries.listApprovals(orgId)
);
const approvals = data?.approvals ?? [];
console.log({
approvals
});
return (
<div className="flex flex-col gap-5">
<Card className="">
<CardHeader className="flex flex-col sm:flex-row sm:items-end lg:items-end gap-2 ">
<div className="flex flex-col items-start gap-2 w-48 mb-0">
<Label htmlFor="approvalState">
{t("filterByApprovalState")}
</Label>
<Select
onValueChange={(newValue) => {
const newSearch = new URLSearchParams(
searchParams
);
newSearch.set("approvalState", newValue);
router.replace(
`${path}?${newSearch.toString()}`
);
}}
value={filters.approvalState ?? "pending"}
>
<SelectTrigger
id="approvalState"
className="w-full"
>
<SelectValue
placeholder={t("selectApprovalState")}
/>
</SelectTrigger>
<SelectContent className="w-full">
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">
Approved
</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
onClick={() => {
refetch();
}}
disabled={isFetching}
className="lg:static gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetching && "animate-spin"
)}
/>
{t("refresh")}
</Button>
</CardHeader>
</Card>
<Card>
<CardHeader>
<ul className="flex flex-col gap-4">
{approvals.map((approval, index) => (
<Fragment key={approval.approvalId}>
<li>
<ApprovalRequest approval={approval} />
</li>
{/* <Separator /> */}
{index < approvals.length - 1 && <Separator />}
{/* <li>
<ApprovalRequest approval={approval} />
</li>
<Separator />
<li>
<ApprovalRequest approval={approval} />
</li> */}
</Fragment>
))}
</ul>
</CardHeader>
</Card>
</div>
);
}
type ApprovalRequestProps = {
approval: ListApprovalsResponse["approvals"][number];
};
function ApprovalRequest({ approval }: ApprovalRequestProps) {
const t = useTranslations();
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="inline-flex items-start md:items-center gap-2">
<LaptopMinimal className="size-4 text-muted-foreground flex-none relative top-2 sm:top-0" />
<span>
<span className="text-primary">
{approval.user.username}
</span>
&nbsp;
{approval.type === "user_device" && (
<span>{t("requestingNewDeviceApproval")}</span>
)}
</span>
</div>
<div className="inline-flex gap-2">
<Button
onClick={() => {}}
className="lg:static gap-2"
type="submit"
>
<Check className="size-4 flex-none" />
{t("approve")}
</Button>
<Button
variant="destructive"
onClick={() => {}}
className="lg:static gap-2"
type="submit"
>
<Ban className="size-4 flex-none" />
{t("deny")}
</Button>
<Button
variant="outline"
onClick={() => {}}
className="lg:static gap-2"
asChild
>
<Link href={"#"}>
{t("viewDetails")}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
</div>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import type {
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListTargetsResponse } from "@server/routers/target"; import type { ListTargetsResponse } from "@server/routers/target";
import type { ListDomainsResponse } from "@server/routers/domain"; import type { ListDomainsResponse } from "@server/routers/domain";
import type { ListApprovalsResponse } from "@server/private/routers/approvals";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; link: string | null;
@@ -311,3 +312,25 @@ export const resourceQueries = {
} }
}) })
}; };
export const approvalFiltersSchema = z.object({
approvalState: z
.enum(["pending", "approved", "denied", "all"])
.optional()
.catch("pending")
});
export const approvalQueries = {
listApprovals: (orgId: string) =>
queryOptions({
queryKey: ["APPROVALS", orgId] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListApprovalsResponse>
>(`/org/${orgId}/approvals`, {
signal
});
return res.data.data;
}
})
};