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

View File

@@ -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<T>` when paginated table PR is merged
export type ListResourcePoliciesResponse = {
policies: Awaited<ReturnType<typeof queryResourcePoliciesBase>>;
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")
);
}
}
}

View File

@@ -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<ResourcePolicy, "resourcePolicyId" | "niceId" | "name" | "orgId">
>;
}>;

View File

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

View File

@@ -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<AxiosResponse<GetOrgUserResponse>>(
`/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"));

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 { redirect } from "next/navigation";
export interface ResourcePoliciesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
searchParams: Promise<Record<string, string>>;
}
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<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";
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;

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