This commit is contained in:
Fred KISSIE
2026-02-14 03:24:01 +01:00
parent dda53dcb16
commit 2305163474
8 changed files with 290 additions and 75 deletions

View File

@@ -166,6 +166,10 @@
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting 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", "authentication": "Authentication",
"protected": "Protected", "protected": "Protected",
"notProtected": "Not Protected", "notProtected": "Not Protected",

View File

@@ -11,7 +11,6 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -36,6 +35,8 @@ import { sql, eq, or, inArray, and, count, ilike, asc } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; 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({ const listResourcePoliciesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -56,7 +57,7 @@ const listResourcePoliciesSchema = z.object({
.optional() .optional()
.catch(1) .catch(1)
.default(1), .default(1),
query: z.string().optional(), query: z.string().optional()
}); });
function queryResourcePoliciesBase() { function queryResourcePoliciesBase() {
@@ -65,43 +66,11 @@ function queryResourcePoliciesBase() {
resourcePolicyId: resourcePolicies.resourcePolicyId, resourcePolicyId: resourcePolicies.resourcePolicyId,
name: resourcePolicies.name, name: resourcePolicies.name,
niceId: resourcePolicies.niceId, niceId: resourcePolicies.niceId,
passwordId: resourcePassword.passwordId, orgId: resourcePolicies.orgId
sso: resourcePolicies.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resourcePolicies.emailWhitelistEnabled,
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
}) })
.from(resourcePolicies) .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
)
);
} }
// TODO: replaced with `PaginatedResponse<T>` when paginated table PR is merged
export type ListResourcePoliciesResponse = {
policies: Awaited<ReturnType<typeof queryResourcePoliciesBase>>;
pagination: { total: number; pageSize: number; page: number; };
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/resource-policies", path: "/org/{orgId}/resource-policies",
@@ -116,8 +85,6 @@ registry.registerPath({
responses: {} responses: {}
}); });
export async function listResourcePolicies( export async function listResourcePolicies(
req: Request, req: Request,
res: Response, res: Response,
@@ -133,10 +100,11 @@ export async function listResourcePolicies(
) )
); );
} }
const { page, pageSize, query, } = const { page, pageSize, query } = parsedQuery.data;
parsedQuery.data;
const parsedParams = listResourcePoliciesParamsSchema.safeParse(req.params); const parsedParams = listResourcePoliciesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
@@ -166,7 +134,7 @@ export async function listResourcePolicies(
); );
} }
let accessibleResourcePolicies: Array<{ resourcePolicyId: number; }>; let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
if (req.user) { if (req.user) {
accessibleResourcePolicies = await db accessibleResourcePolicies = await db
.select({ .select({
@@ -175,7 +143,10 @@ export async function listResourcePolicies(
.from(userResources) .from(userResources)
.fullJoin( .fullJoin(
roleResources, roleResources,
eq(userResources.resourcePolicyId, roleResources.resourcePolicyId) eq(
userResources.resourcePolicyId,
roleResources.resourcePolicyId
)
) )
.where( .where(
or( or(
@@ -198,7 +169,10 @@ export async function listResourcePolicies(
const conditions = [ const conditions = [
and( and(
inArray(resourcePolicies.resourcePolicyId, accessibleResourceIds), inArray(
resourcePolicies.resourcePolicyId,
accessibleResourceIds
),
eq(resourcePolicies.orgId, orgId) eq(resourcePolicies.orgId, orgId)
) )
]; ];
@@ -207,13 +181,12 @@ export async function listResourcePolicies(
conditions.push( conditions.push(
or( or(
ilike(resourcePolicies.name, "%" + query + "%"), ilike(resourcePolicies.name, "%" + query + "%"),
ilike(resourcePolicies.niceId, "%" + query + "%"), ilike(resourcePolicies.niceId, "%" + query + "%")
) )
); );
} }
const baseQuery = queryResourcePoliciesBase() const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
.where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_policies")); const countQuery = db.$count(baseQuery.as("filtered_policies"));
@@ -240,8 +213,6 @@ export async function listResourcePolicies(
message: "Resources retrieved successfully", message: "Resources retrieved successfully",
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(

View File

@@ -1,3 +1,6 @@
import type { ResourcePolicy } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type GetMaintenanceInfoResponse = { export type GetMaintenanceInfoResponse = {
resourceId: number; resourceId: number;
name: string; name: string;
@@ -8,3 +11,9 @@ export type GetMaintenanceInfoResponse = {
maintenanceMessage: string | null; maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null; maintenanceEstimatedTime: string | null;
}; };
export type ListResourcePoliciesResponse = PaginatedResponse<{
policies: Array<
Pick<ResourcePolicy, "resourcePolicyId" | "niceId" | "name" | "orgId">
>;
}>;

View File

@@ -11,7 +11,6 @@ import {
GetLoginPageResponse GetLoginPageResponse
} from "@server/routers/loginPage/types"; } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
export interface AuthPageProps { export interface AuthPageProps {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;

View File

@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation"; import { orgNavSections } from "@app/app/navigation";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const t = await getTranslations(); const t = await getTranslations();
try { try {
const getOrgUser = cache(() => const orgUser = await getCachedOrgUser(params.orgId, user.userId);
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`,
cookie
)
);
const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error(t("userErrorNotAdminOrOwner")); throw new Error(t("userErrorNotAdminOrOwner"));

View File

@@ -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 { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export interface ResourcePoliciesPageProps { export interface ResourcePoliciesPageProps {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
} }
export default async function ResourcePoliciesPage( export default async function ResourcePoliciesPage(
@@ -10,5 +20,52 @@ export default async function ResourcePoliciesPage(
) { ) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); 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<ListResourcePoliciesResponse>
>(
`/org/${params.orgId}/resource-policies?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
policies = responseData.policies;
pagination = responseData.pagination;
} catch (e) {}
return (
<>
<SettingsSectionTitle
title={t("resourcePoliciesTitle")}
description={t("resourcePoliciesDescription")}
/>
<OrgProvider org={org}>
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider>
</>
);
} }

View File

@@ -1,18 +1,18 @@
"use client"; "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 { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } 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 { import {
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
@@ -21,21 +21,19 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; 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 { build } from "@server/build";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; 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 = { export type AuthPageCustomizationProps = {
orgId: string; orgId: string;

View File

@@ -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<ResourcePolicyRow>;
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<ResourcePolicyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const policyRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${policyRow.orgId}/settings/resources/proxy/${policyRow.niceId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
// setSelectedResource(resourceRow);
// setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${policyRow.orgId}/settings/resources/proxy/${policyRow.niceId}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
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 (
<>
<ControlledDataTable
columns={proxyColumns}
rows={policies}
tableId="resource-policies"
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
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"
/>
</>
);
}