process approvals on the frontend

This commit is contained in:
Fred KISSIE
2026-01-14 03:31:49 +01:00
parent bc20a34a49
commit 0c5daa7173
5 changed files with 107 additions and 36 deletions

View File

@@ -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.",

View File

@@ -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")
}); });

View File

@@ -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,

View File

@@ -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">
<Button {approval.decision === "pending" && (
onClick={() => {}} <form action={formAction} className="inline-flex gap-2">
className="lg:static gap-2" <Button
type="submit" value="approved"
> name="decision"
<Check className="size-4 flex-none" /> className="gap-2"
{t("approve")} type="submit"
</Button> loading={isSubmitting}
<Button >
variant="destructive" <Check className="size-4 flex-none" />
onClick={() => {}} {t("approve")}
className="lg:static gap-2" </Button>
type="submit" <Button
> value="denied"
<Ban className="size-4 flex-none" /> name="decision"
{t("deny")} variant="destructive"
</Button> className="gap-2"
type="submit"
loading={isSubmitting}
>
<Ban className="size-4 flex-none" />
{t("deny")}
</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={"#"}>

View File

@@ -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 = {