From 230516347460c5902c52dcaef409d09481f649b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 14 Feb 2026 03:24:01 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + .../routers/resource/listResourcePolicies.ts | 71 ++----- server/routers/resource/types.ts | 9 + .../settings/general/auth-page/page.tsx | 1 - src/app/[orgId]/settings/layout.tsx | 9 +- .../settings/resources/policies/page.tsx | 61 +++++- src/components/AuthPageBrandingForm.tsx | 28 ++- src/components/ResourcePoliciesTable.tsx | 182 ++++++++++++++++++ 8 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 src/components/ResourcePoliciesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4fec9cf6c..84dad51a2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -166,6 +166,10 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", + "resourcePoliciesTitle": "Manage Resource Policies", + "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", + "resourcePoliciesSearch": "Search policies...", + "resourcePoliciesAdd": "Add Policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", diff --git a/server/private/routers/resource/listResourcePolicies.ts b/server/private/routers/resource/listResourcePolicies.ts index cc280f235..940a4b781 100644 --- a/server/private/routers/resource/listResourcePolicies.ts +++ b/server/private/routers/resource/listResourcePolicies.ts @@ -11,7 +11,6 @@ * This file is not licensed under the AGPLv3. */ - import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { @@ -36,6 +35,8 @@ import { sql, eq, or, inArray, and, count, ilike, asc } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; const listResourcePoliciesParamsSchema = z.strictObject({ orgId: z.string() @@ -56,7 +57,7 @@ const listResourcePoliciesSchema = z.object({ .optional() .catch(1) .default(1), - query: z.string().optional(), + query: z.string().optional() }); function queryResourcePoliciesBase() { @@ -65,43 +66,11 @@ function queryResourcePoliciesBase() { resourcePolicyId: resourcePolicies.resourcePolicyId, name: resourcePolicies.name, niceId: resourcePolicies.niceId, - passwordId: resourcePassword.passwordId, - sso: resourcePolicies.sso, - pincodeId: resourcePincode.pincodeId, - whitelist: resourcePolicies.emailWhitelistEnabled, - headerAuthId: resourceHeaderAuth.headerAuthId, - headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + orgId: resourcePolicies.orgId }) - .from(resourcePolicies) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourcePolicyId, resourcePolicies.resourcePolicyId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourcePolicyId, - resourcePolicies.resourcePolicyId - ) - ); - + .from(resourcePolicies); } -// TODO: replaced with `PaginatedResponse` when paginated table PR is merged -export type ListResourcePoliciesResponse = { - policies: Awaited>; - pagination: { total: number; pageSize: number; page: number; }; -}; - registry.registerPath({ method: "get", path: "/org/{orgId}/resource-policies", @@ -116,8 +85,6 @@ registry.registerPath({ responses: {} }); - - export async function listResourcePolicies( req: Request, res: Response, @@ -133,10 +100,11 @@ export async function listResourcePolicies( ) ); } - const { page, pageSize, query, } = - parsedQuery.data; + const { page, pageSize, query } = parsedQuery.data; - const parsedParams = listResourcePoliciesParamsSchema.safeParse(req.params); + const parsedParams = listResourcePoliciesParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -166,7 +134,7 @@ export async function listResourcePolicies( ); } - let accessibleResourcePolicies: Array<{ resourcePolicyId: number; }>; + let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>; if (req.user) { accessibleResourcePolicies = await db .select({ @@ -175,7 +143,10 @@ export async function listResourcePolicies( .from(userResources) .fullJoin( roleResources, - eq(userResources.resourcePolicyId, roleResources.resourcePolicyId) + eq( + userResources.resourcePolicyId, + roleResources.resourcePolicyId + ) ) .where( or( @@ -198,7 +169,10 @@ export async function listResourcePolicies( const conditions = [ and( - inArray(resourcePolicies.resourcePolicyId, accessibleResourceIds), + inArray( + resourcePolicies.resourcePolicyId, + accessibleResourceIds + ), eq(resourcePolicies.orgId, orgId) ) ]; @@ -207,13 +181,12 @@ export async function listResourcePolicies( conditions.push( or( ilike(resourcePolicies.name, "%" + query + "%"), - ilike(resourcePolicies.niceId, "%" + query + "%"), + ilike(resourcePolicies.niceId, "%" + query + "%") ) ); } - const baseQuery = queryResourcePoliciesBase() - .where(and(...conditions)); + const baseQuery = queryResourcePoliciesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_policies")); @@ -240,12 +213,10 @@ export async function listResourcePolicies( message: "Resources retrieved successfully", status: HttpCode.OK }); - - } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index 9dcdcd086..223154a01 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -1,3 +1,6 @@ +import type { ResourcePolicy } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + export type GetMaintenanceInfoResponse = { resourceId: number; name: string; @@ -8,3 +11,9 @@ export type GetMaintenanceInfoResponse = { maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; }; + +export type ListResourcePoliciesResponse = PaginatedResponse<{ + policies: Array< + Pick + >; +}>; diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 0bd482864..f245c8f86 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -11,7 +11,6 @@ import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; -import { redirect } from "next/navigation"; export interface AuthPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 34ed3ac2f..310d36ca0 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { orgNavSections } from "@app/app/navigation"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; export const dynamic = "force-dynamic"; @@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); try { - const getOrgUser = cache(() => - internal.get>( - `/org/${params.orgId}/user/${user.userId}`, - cookie - ) - ); - const orgUser = await getOrgUser(); + const orgUser = await getCachedOrgUser(params.orgId, user.userId); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { throw new Error(t("userErrorNotAdminOrOwner")); diff --git a/src/app/[orgId]/settings/resources/policies/page.tsx b/src/app/[orgId]/settings/resources/policies/page.tsx index 59e7120f9..e641696ef 100644 --- a/src/app/[orgId]/settings/resources/policies/page.tsx +++ b/src/app/[orgId]/settings/resources/policies/page.tsx @@ -1,8 +1,18 @@ +import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; export interface ResourcePoliciesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ResourcePoliciesPage( @@ -10,5 +20,52 @@ export default async function ResourcePoliciesPage( ) { const params = await props.params; const t = await getTranslations(); - return <>; + const searchParams = new URLSearchParams(await props.searchParams); + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + let policies: ListResourcePoliciesResponse["policies"] = []; + let pagination: ListResourcePoliciesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policies?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + policies = responseData.policies; + pagination = responseData.pagination; + } catch (e) {} + + return ( + <> + + + + + + + ); } diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index a19980627..f3c1da524 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -1,18 +1,18 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { startTransition, useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import z from "zod"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; import { SettingsSection, SettingsSectionBody, @@ -21,21 +21,19 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { useTranslations } from "next-intl"; -import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; -import { Input } from "./ui/input"; -import { ExternalLink, InfoIcon, XIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useRouter } from "next/navigation"; -import { toast } from "@app/hooks/useToast"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { XIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; export type AuthPageCustomizationProps = { orgId: string; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx new file mode 100644 index 000000000..0d348b2a3 --- /dev/null +++ b/src/components/ResourcePoliciesTable.tsx @@ -0,0 +1,182 @@ +"use client"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { PaginationState } from "@tanstack/react-table"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import type { ExtendedColumnDef } from "./ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "./ui/dropdown-menu"; +import { Button } from "./ui/button"; +import { MoreHorizontal, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useDebouncedCallback } from "use-debounce"; + +type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; + +export type ResourcePoliciesTableProps = { + policies: Array; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export function ResourcePoliciesTable({ + policies, + orgId, + pagination, + rowCount +}: ResourcePoliciesTableProps) { + const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); + + const { env } = useEnvContext(); + + const api = createApiClient({ env }); + + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + + const refreshData = () => { + startTransition(() => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + + const proxyColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => {t("name")} + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => {t("identifier")}, + cell: ({ row }) => { + return {row.original.niceId || "-"}; + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const policyRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + // setSelectedResource(resourceRow); + // setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ); + } + } + ]; + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + <> + + startNavigation(() => + router.push( + `/${orgId}/settings/resources/policies/create` + ) + ) + } + addButtonText={t("resourcePoliciesAdd")} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility + columnVisibility={{ niceId: false }} + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +}