mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
✨ process approvals on the frontend
This commit is contained in:
@@ -462,7 +462,7 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"deny": "Deny",
|
"deny": "Deny",
|
||||||
"viewDetails": "View Details",
|
"viewDetails": "View Details",
|
||||||
"requestingNewDeviceApproval": "is requesting new device",
|
"requestingNewDeviceApproval": "requested a new device",
|
||||||
"resetFilters": "Reset Filters",
|
"resetFilters": "Reset Filters",
|
||||||
"totalBlocked": "Requests Blocked By Pangolin",
|
"totalBlocked": "Requests Blocked By Pangolin",
|
||||||
"totalRequests": "Total Requests",
|
"totalRequests": "Total Requests",
|
||||||
@@ -752,8 +752,13 @@
|
|||||||
"accessRoleUpdateSubmit": "Update Role",
|
"accessRoleUpdateSubmit": "Update Role",
|
||||||
"accessRoleUpdated": "Role updated",
|
"accessRoleUpdated": "Role updated",
|
||||||
"accessRoleUpdatedDescription": "The role has been successfully updated.",
|
"accessRoleUpdatedDescription": "The role has been successfully updated.",
|
||||||
|
"accessApprovalUpdated": "Approval processed",
|
||||||
|
"accessApprovalApprovedDescription": "Request approved.",
|
||||||
|
"accessApprovalDeniedDescription": "Request denied.",
|
||||||
"accessRoleErrorUpdate": "Failed to update role",
|
"accessRoleErrorUpdate": "Failed to update role",
|
||||||
"accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
|
"accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
|
||||||
|
"accessApprovalErrorUpdate": "Failed to process approval",
|
||||||
|
"accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.",
|
||||||
"accessRoleErrorNewRequired": "New role is required",
|
"accessRoleErrorNewRequired": "New role is required",
|
||||||
"accessRoleErrorRemove": "Failed to remove role",
|
"accessRoleErrorRemove": "Failed to remove role",
|
||||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const querySchema = z.strictObject({
|
|||||||
approvalState: z
|
approvalState: z
|
||||||
.enum(["pending", "approved", "denied", "all"])
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("pending")
|
.default("all")
|
||||||
.catch("all")
|
.catch("all")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { approvals, clients, db, orgs } from "@server/db";
|
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
||||||
import { getOrgTierData } from "@server/lib/billing";
|
import { getOrgTierData } from "@server/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -27,13 +27,15 @@ import type { NextFunction, Request, Response } from "express";
|
|||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
approvalId: z.int()
|
approvalId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
decision: z.enum(["approved", "denied"])
|
decision: z.enum(["approved", "denied"])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ProcessApprovalResponse = Approval;
|
||||||
|
|
||||||
export async function processPendingApproval(
|
export async function processPendingApproval(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useTranslations } from "next-intl";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { Card, CardHeader } from "./ui/card";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { approvalFiltersSchema, approvalQueries } from "@app/lib/queries";
|
||||||
|
import type {
|
||||||
|
ListApprovalsResponse,
|
||||||
|
ProcessApprovalResponse
|
||||||
|
} from "@server/private/routers/approvals";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { ArrowRight, Ban, Check, LaptopMinimal, 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 { Badge } from "./ui/badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Card, CardHeader } from "./ui/card";
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -13,12 +26,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "./ui/select";
|
} 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 { Separator } from "./ui/separator";
|
||||||
import type { ListApprovalsResponse } from "@server/private/routers/approvals";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export type ApprovalFeedProps = {
|
export type ApprovalFeedProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -60,7 +68,7 @@ export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
|
|||||||
`${path}?${newSearch.toString()}`
|
`${path}?${newSearch.toString()}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
value={filters.approvalState ?? "pending"}
|
value={filters.approvalState ?? "all"}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="approvalState"
|
id="approvalState"
|
||||||
@@ -109,7 +117,11 @@ export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
|
|||||||
{approvals.map((approval, index) => (
|
{approvals.map((approval, index) => (
|
||||||
<Fragment key={approval.approvalId}>
|
<Fragment key={approval.approvalId}>
|
||||||
<li>
|
<li>
|
||||||
<ApprovalRequest approval={approval} />
|
<ApprovalRequest
|
||||||
|
approval={approval}
|
||||||
|
orgId={orgId}
|
||||||
|
onSuccess={() => refetch()}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
{index < approvals.length - 1 && <Separator />}
|
{index < approvals.length - 1 && <Separator />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -129,10 +141,47 @@ export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
|
|||||||
|
|
||||||
type ApprovalRequestProps = {
|
type ApprovalRequestProps = {
|
||||||
approval: ListApprovalsResponse["approvals"][number];
|
approval: ListApprovalsResponse["approvals"][number];
|
||||||
|
orgId: string;
|
||||||
|
onSuccess?: (data: ProcessApprovalResponse) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ApprovalRequest({ approval }: ApprovalRequestProps) {
|
function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [_, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
async function onSubmit(_previousState: any, formData: FormData) {
|
||||||
|
const decision = formData.get("decision");
|
||||||
|
const res = await api
|
||||||
|
.put<
|
||||||
|
AxiosResponse<ProcessApprovalResponse>
|
||||||
|
>(`/org/${orgId}/approvals/${approval.approvalId}`, { decision })
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("accessApprovalErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("accessApprovalErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const result = res.data.data;
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("accessApprovalUpdated"),
|
||||||
|
description:
|
||||||
|
result.decision === "approved"
|
||||||
|
? t("accessApprovalApprovedDescription")
|
||||||
|
: t("accessApprovalDeniedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.(res.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="inline-flex items-start md:items-center gap-2">
|
<div className="inline-flex items-start md:items-center gap-2">
|
||||||
@@ -148,27 +197,42 @@ function ApprovalRequest({ approval }: ApprovalRequestProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex gap-2">
|
<div className="inline-flex gap-2">
|
||||||
|
{approval.decision === "pending" && (
|
||||||
|
<form action={formAction} className="inline-flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {}}
|
value="approved"
|
||||||
className="lg:static gap-2"
|
name="decision"
|
||||||
|
className="gap-2"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
<Check className="size-4 flex-none" />
|
<Check className="size-4 flex-none" />
|
||||||
{t("approve")}
|
{t("approve")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
value="denied"
|
||||||
|
name="decision"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {}}
|
className="gap-2"
|
||||||
className="lg:static gap-2"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
<Ban className="size-4 flex-none" />
|
<Ban className="size-4 flex-none" />
|
||||||
{t("deny")}
|
{t("deny")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{approval.decision === "approved" && (
|
||||||
|
<Badge variant="green">{t("approved")}</Badge>
|
||||||
|
)}
|
||||||
|
{approval.decision === "denied" && (
|
||||||
|
<Badge variant="red">{t("denied")}</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
className="lg:static gap-2"
|
className="gap-2"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href={"#"}>
|
<Link href={"#"}>
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ export const approvalFiltersSchema = z.object({
|
|||||||
approvalState: z
|
approvalState: z
|
||||||
.enum(["pending", "approved", "denied", "all"])
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
.optional()
|
.optional()
|
||||||
.catch("pending")
|
.catch("all")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const approvalQueries = {
|
export const approvalQueries = {
|
||||||
|
|||||||
Reference in New Issue
Block a user