mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
✨ approval list UI
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
183
src/components/ApprovalFeed.tsx
Normal file
183
src/components/ApprovalFeed.tsx
Normal 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user