mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-09 22:04:16 +00:00
Merge branch 'feat/resource-policies' into resource-policies
This commit is contained in:
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
23
src/app/[orgId]/settings/(private)/policies/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface PolicyLayoutPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
let org: GetOrgResponse | null = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings`);
|
||||
}
|
||||
|
||||
return <OrgProvider org={org}>{props.children}</OrgProvider>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface EditPolicyPageProps {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let policyResponse: GetResourcePolicyResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourcePolicyResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/resource-policy/${params.niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
policyResponse = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
}
|
||||
|
||||
if (!policyResponse) {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePolicySetting", {
|
||||
policyName: policyResponse.name
|
||||
})}
|
||||
description={t("resourcePolicySettingDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResourcePolicyProvider policy={policyResponse}>
|
||||
<EditPolicyForm />
|
||||
</ResourcePolicyProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export interface CreateResourcePolicyPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function CreateResourcePolicyPage(
|
||||
props: CreateResourcePolicyPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("resourcePoliciesCreate")}
|
||||
description={t("resourcePoliciesCreateDescription")}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CreatePolicyForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 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<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default async function ResourcePoliciesPage(
|
||||
props: ResourcePoliciesPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
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")}
|
||||
/>
|
||||
|
||||
<ResourcePoliciesTable
|
||||
policies={policies}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
||||
});
|
||||
navItems.push({
|
||||
title: t("rules"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
||||
});
|
||||
// navItems.push({
|
||||
// title: t("rules"),
|
||||
// href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
|
||||
// });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,13 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useTransition,
|
||||
useEffect
|
||||
} from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -218,7 +224,7 @@ export default function Page() {
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createLoading, startTransition] = useTransition();
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
const [niceId, setNiceId] = useState<string>("");
|
||||
|
||||
@@ -328,7 +334,7 @@ export default function Page() {
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description:
|
||||
build == "saas"
|
||||
build === "saas"
|
||||
? t("resourceRawDescriptionCloud")
|
||||
: t("resourceRawDescription")
|
||||
}
|
||||
@@ -473,8 +479,6 @@ export default function Page() {
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
setCreateLoading(true);
|
||||
|
||||
const baseData = baseForm.getValues();
|
||||
const isHttp = baseData.http;
|
||||
|
||||
@@ -610,8 +614,6 @@ export default function Page() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1465,7 +1467,7 @@ export default function Page() {
|
||||
console.log(httpForm.getValues());
|
||||
|
||||
if (baseValid && settingsValid) {
|
||||
onSubmit();
|
||||
startTransition(onSubmit);
|
||||
}
|
||||
}}
|
||||
loading={createLoading}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
GlobeIcon,
|
||||
GlobeLock,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldIcon,
|
||||
SquareMousePointer,
|
||||
TicketCheck,
|
||||
Unplug,
|
||||
@@ -99,7 +101,7 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas"
|
||||
...(build === "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarRemoteExitNodes",
|
||||
@@ -134,6 +136,24 @@ export const orgNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarPolicies",
|
||||
|
||||
icon: <ShieldIcon className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarResourcePolicies",
|
||||
href: "/{orgId}/settings/policies/resource",
|
||||
icon: (
|
||||
<GlobeIcon className="size-4 flex-none" />
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
|
||||
@@ -28,15 +28,14 @@ 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 { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
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";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
|
||||
@@ -193,22 +193,17 @@ export default function ProxyResourcesTable({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
const deleteResource = async (resourceId: number) => {
|
||||
await api.delete(`/resource/${resourceId}`).catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
});
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
@@ -770,7 +765,11 @@ export default function ProxyResourcesTable({
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
onConfirm={async () =>
|
||||
startTransition(() =>
|
||||
deleteResource(selectedResource!.id)
|
||||
)
|
||||
}
|
||||
string={selectedResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
|
||||
306
src/components/ResourcePoliciesTable.tsx
Normal file
306
src/components/ResourcePoliciesTable.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type {
|
||||
AttachedResource,
|
||||
ListResourcePoliciesResponse
|
||||
} from "@server/routers/resource/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Waypoints
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import type { ExtendedColumnDef } from "./ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "./ui/dropdown-menu";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
|
||||
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResourcePolicy, setSelectedResourcePolicy] =
|
||||
useState<ResourcePolicyRow | null>(null);
|
||||
|
||||
const deleteResourcePolicy = async (resourcePolicyId: number) => {
|
||||
await api
|
||||
.delete(`/resource-policy/${resourcePolicyId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function ResourceListCell({
|
||||
resources
|
||||
}: {
|
||||
resources?: AttachedResource[];
|
||||
}) {
|
||||
if (!resources || resources.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="LOOK_FOR_ME"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResourcesEmpty")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResources", {
|
||||
count: resources.length
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-70">
|
||||
{resources.map((resource) => (
|
||||
<DropdownMenuItem
|
||||
key={resource.resourceId}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.name}
|
||||
</div>
|
||||
<span
|
||||
className={`capitalize text-muted-foreground`}
|
||||
>
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.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: "resources",
|
||||
accessorKey: "resources",
|
||||
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("resourcePoliciesAttachedResourcesColumnTitle")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <ResourceListCell resources={row.original.resources} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
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/policies/resource/${policyRow.niceId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResourcePolicy(policyRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${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 (
|
||||
<>
|
||||
{selectedResourcePolicy && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedResourcePolicy(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourcePolicyQuestionRemove")}</p>
|
||||
<p>{t("resourcePolicyMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourcePolicyDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteResourcePolicy(
|
||||
selectedResourcePolicy.resourcePolicyId
|
||||
)
|
||||
}
|
||||
string={selectedResourcePolicy.name}
|
||||
title={t("resourcePolicyDelete")}
|
||||
/>
|
||||
)}
|
||||
<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/policies/resource/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourcePoliciesAdd")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{ niceId: false }}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -61,12 +61,19 @@ export function SettingsSectionBody({
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols
|
||||
cols = 1
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||
TValue | undefined
|
||||
>(defaultValue);
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
|
||||
const selected = isControlled
|
||||
? (controlledValue ?? undefined)
|
||||
: uncontrolledSelected;
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
|
||||
if (!isControlled) setUncontrolledSelected(typedValue);
|
||||
onChange?.(typedValue);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, 1fr)`
|
||||
}}
|
||||
className="grid md:grid-cols-(--cols) gap-4"
|
||||
>
|
||||
{options.map((option: StrategyOption<TValue>) => (
|
||||
<label
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyAuthMethodsSectionForm ───────────────────────────────────────
|
||||
|
||||
const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
|
||||
export type CreatePolicyAuthMethodsSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
};
|
||||
|
||||
export function CreatePolicyAuthMethodsSectionForm({
|
||||
form: parentForm
|
||||
}: CreatePolicyAuthMethodsSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: null
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("password", values.password as any);
|
||||
parentForm.setValue("pincode", values.pincode as any);
|
||||
parentForm.setValue("headerAuth", values.headerAuth as any);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const password = useWatch({
|
||||
control: form.control,
|
||||
name: "password"
|
||||
});
|
||||
const pincode = useWatch({
|
||||
control: form.control,
|
||||
name: "pincode"
|
||||
});
|
||||
const headerAuth = useWatch({
|
||||
control: form.control,
|
||||
name: "headerAuth"
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const pincodeForm = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: "" }
|
||||
});
|
||||
|
||||
const headerAuthForm = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyAuthMethodAdd")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Password Credenza */}
|
||||
<Credenza
|
||||
open={isSetPasswordOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPasswordOpen(val);
|
||||
if (!val) passwordForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit((data) => {
|
||||
form.setValue("password", data);
|
||||
setIsSetPasswordOpen(false);
|
||||
passwordForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-password-form">
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Pincode Credenza */}
|
||||
<Credenza
|
||||
open={isSetPincodeOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPincodeOpen(val);
|
||||
if (!val) pincodeForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...pincodeForm}>
|
||||
<form
|
||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||
form.setValue("pincode", data);
|
||||
setIsSetPincodeOpen(false);
|
||||
pincodeForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={pincodeForm.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-pincode-form">
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Header Auth Credenza */}
|
||||
<Credenza
|
||||
open={isSetHeaderAuthOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetHeaderAuthOpen(val);
|
||||
if (!val) headerAuthForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...headerAuthForm}>
|
||||
<form
|
||||
onSubmit={headerAuthForm.handleSubmit(
|
||||
(data) => {
|
||||
form.setValue("headerAuth", data);
|
||||
setIsSetHeaderAuthOpen(false);
|
||||
headerAuthForm.reset();
|
||||
}
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="set-header-auth-form"
|
||||
>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-toggle"
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-header-auth-form">
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: password
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
password
|
||||
? () => form.setValue("password", null)
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
>
|
||||
{password
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: pincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
pincode
|
||||
? () => form.setValue("pincode", null)
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
>
|
||||
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
{headerAuth
|
||||
? t(
|
||||
"resourceHeaderAuthProtectionEnabled"
|
||||
)
|
||||
: t(
|
||||
"resourceHeaderAuthProtectionDisabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={
|
||||
headerAuth
|
||||
? () =>
|
||||
form.setValue("headerAuth", null)
|
||||
: () => setIsSetHeaderAuthOpen(true)
|
||||
}
|
||||
>
|
||||
{headerAuth
|
||||
? t("headerAuthRemove")
|
||||
: t("headerAuthAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
270
src/components/resource-policy/CreatePolicyForm.tsx
Normal file
270
src/components/resource-policy/CreatePolicyForm.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { type PolicyFormValues, createPolicySchema } from ".";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgs, type ResourcePolicy } from "@server/db";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
|
||||
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
|
||||
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
|
||||
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
|
||||
|
||||
// ─── CreatePolicyForm ─────────────────────────────────────────────────────────
|
||||
|
||||
export type CreatePolicyFormProps = {};
|
||||
|
||||
export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
);
|
||||
const isMaxmindAsnAvailable = !!(
|
||||
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0
|
||||
);
|
||||
|
||||
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
|
||||
orgQueries.roles({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
|
||||
orgQueries.users({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||
orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm<PolicyFormValues>({
|
||||
resolver: zodResolver(createPolicySchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
sso: true,
|
||||
skipToIdpId: null,
|
||||
emailWhitelistEnabled: false,
|
||||
roles: [],
|
||||
users: [],
|
||||
emails: [],
|
||||
applyRules: false,
|
||||
rules: [],
|
||||
password: null,
|
||||
headerAuth: null,
|
||||
pincode: null
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.post<AxiosResponse<ResourcePolicy>>(
|
||||
`/org/${org.org.orgId}/resource-policy/`,
|
||||
{
|
||||
name: payload.name,
|
||||
// access control
|
||||
sso: payload.sso,
|
||||
roleIds: payload.roles.map((r) => r.id),
|
||||
userIds: payload.users.map((u) => u.id),
|
||||
skipToIdpId: payload.skipToIdpId,
|
||||
// auth methods
|
||||
password: payload.password?.password,
|
||||
pincode: payload.pincode?.pincode,
|
||||
headerAuth: payload.headerAuth,
|
||||
// email OTP
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails.map((email) => email.text),
|
||||
// rules
|
||||
applyRules: payload.applyRules,
|
||||
rules: payload.rules
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorCreateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const niceId = res.data.data.niceId;
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/policies/resource/${niceId}`
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyCreatedSuccess")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorCreate"),
|
||||
description: t("policyErrorCreateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allRoles = useMemo(
|
||||
() =>
|
||||
orgRoles
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin"),
|
||||
[orgRoles]
|
||||
);
|
||||
|
||||
const allUsers = useMemo(
|
||||
() =>
|
||||
orgUsers.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
})),
|
||||
[orgUsers]
|
||||
);
|
||||
|
||||
const allIdps = useMemo(() => {
|
||||
if (build === "saas") {
|
||||
if (isPaidUser(tierMatrix.orgOidc)) {
|
||||
return orgIdps.map((idp) => ({
|
||||
id: idp.idpId,
|
||||
text: idp.name
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name }));
|
||||
}
|
||||
return [];
|
||||
}, [orgIdps, isPaidUser]);
|
||||
|
||||
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SettingsContainer>
|
||||
{/* Name */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourcePolicyName")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyNameDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"resourcePolicyNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<CreatePolicyUsersRolesSectionForm
|
||||
form={form}
|
||||
allRoles={allRoles}
|
||||
allUsers={allUsers}
|
||||
allIdps={allIdps}
|
||||
/>
|
||||
<CreatePolicyAuthMethodsSectionForm form={form} />
|
||||
<CreatePolicyOtpEmailSectionForm
|
||||
form={form}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
/>
|
||||
<CreatePolicyRulesSectionForm
|
||||
form={form}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex py-6 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => startTransition(onSubmit)}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("resourcePoliciesCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel
|
||||
} from "@app/components/ui/form";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
import { InfoIcon, Plus } from "lucide-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyOtpEmailSectionForm ──────────────────────────────────────────
|
||||
|
||||
export type CreatePolicyOtpEmailSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export function CreatePolicyOtpEmailSectionForm({
|
||||
form: parentForm,
|
||||
emailEnabled
|
||||
}: CreatePolicyOtpEmailSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
emailWhitelistEnabled: false,
|
||||
emails: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
values.emailWhitelistEnabled as boolean
|
||||
);
|
||||
parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const whitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyOtpEmailAdd")}
|
||||
</Button>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={false}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("emailWhitelistEnabled", val);
|
||||
}}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && emailEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t("otpEmailWhitelistList")}
|
||||
info={t(
|
||||
"otpEmailWhitelistListDescription"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size="sm"
|
||||
validateTag={(tag) => {
|
||||
return z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
t(
|
||||
"otpEmailErrorInvalid"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(tag).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t("otpEmailEnter")}
|
||||
tags={form.getValues().emails}
|
||||
setTags={(newEmails) => {
|
||||
form.setValue(
|
||||
"emails",
|
||||
newEmails as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
1092
src/components/resource-policy/CreatePolicyRulesSectionForm.tsx
Normal file
1092
src/components/resource-policy/CreatePolicyRulesSectionForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── CreatePolicyUsersRolesSectionForm ────────────────────────────────────────
|
||||
|
||||
export type CreatePolicyUsersRolesSectionFormProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
allIdps: { id: number; text: string }[];
|
||||
};
|
||||
|
||||
export function CreatePolicyUsersRolesSectionForm({
|
||||
form: parentForm,
|
||||
allRoles,
|
||||
allUsers,
|
||||
allIdps
|
||||
}: CreatePolicyUsersRolesSectionFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
sso: true,
|
||||
skipToIdpId: null,
|
||||
roles: [],
|
||||
users: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("sso", values.sso as boolean);
|
||||
parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null);
|
||||
parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]);
|
||||
parentForm.setValue("users", values.users as [Tag, ...Tag[]]);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||
const selectedIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceUsersRoles")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyUsersRolesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={ssoEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().roles}
|
||||
setTags={(newRoles) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().users}
|
||||
setTags={(newUsers) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
form.setValue("skipToIdpId", null);
|
||||
} else {
|
||||
const id = parseInt(value);
|
||||
form.setValue("skipToIdpId", id);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-1">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("none")}
|
||||
</SelectItem>
|
||||
{allIdps.map((idp) => (
|
||||
<SelectItem
|
||||
key={idp.id}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{idp.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("defaultIdentityProviderDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPolicySchema } from ".";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
|
||||
import { Binary, Bot, Key, Plus } from "lucide-react";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
// ─── PolicyAuthMethodsSection ─────────────────────────────────────────────────
|
||||
|
||||
const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
|
||||
export function EditPolicyAuthMethodsSectionForm({
|
||||
readonly
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
}) {
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
password: true,
|
||||
pincode: true,
|
||||
headerAuth: true
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
|
||||
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
|
||||
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
|
||||
|
||||
const password = form.watch("password");
|
||||
const pincode = form.watch("pincode");
|
||||
const headerAuth = form.watch("headerAuth");
|
||||
|
||||
// If explicitly removed (set to `null`) it means the value has been removed
|
||||
// in the other case (`undefined` or object value), check if the value has been modified
|
||||
// and fallback to the policy default value
|
||||
const hasPassword =
|
||||
password !== null ? Boolean(password ?? policy.passwordId) : false;
|
||||
|
||||
const hasPincode =
|
||||
pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false;
|
||||
|
||||
const hasHeaderAuth =
|
||||
headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
hasPassword || hasPincode || hasHeaderAuth
|
||||
);
|
||||
|
||||
const passwordForm = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const pincodeForm = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: "" }
|
||||
});
|
||||
|
||||
const headerAuthForm = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: { user: "", password: "", extendedCompatibility: true }
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
const responseArray: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
|
||||
if (typeof payload.password !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{
|
||||
password: payload.password?.password ?? null
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payload.pincode !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{
|
||||
pincode: payload.pincode?.pincode ?? null
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payload.headerAuth !== "undefined") {
|
||||
responseArray.push(
|
||||
api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{
|
||||
headerAuth: payload.headerAuth
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const responseList = await Promise.all(responseArray);
|
||||
|
||||
if (responseList.every((res) => res && res.status === 200)) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!readonly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyAuthMethodAdd")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||
<p>{t("resourcePolicyAuthMethodsEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Password Credenza */}
|
||||
<Credenza
|
||||
open={isSetPasswordOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPasswordOpen(val);
|
||||
if (!val) passwordForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit((data) => {
|
||||
form.setValue("password", data);
|
||||
setIsSetPasswordOpen(false);
|
||||
passwordForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-password-form">
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Pincode Credenza */}
|
||||
<Credenza
|
||||
open={isSetPincodeOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetPincodeOpen(val);
|
||||
if (!val) pincodeForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...pincodeForm}>
|
||||
<form
|
||||
onSubmit={pincodeForm.handleSubmit((data) => {
|
||||
form.setValue("pincode", data);
|
||||
setIsSetPincodeOpen(false);
|
||||
pincodeForm.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={pincodeForm.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-pincode-form">
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{/* Header Auth Credenza */}
|
||||
<Credenza
|
||||
open={isSetHeaderAuthOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsSetHeaderAuthOpen(val);
|
||||
if (!val) headerAuthForm.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...headerAuthForm}>
|
||||
<form
|
||||
onSubmit={headerAuthForm.handleSubmit(
|
||||
(data) => {
|
||||
form.setValue("headerAuth", data);
|
||||
setIsSetHeaderAuthOpen(false);
|
||||
headerAuthForm.reset();
|
||||
}
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="set-header-auth-form"
|
||||
>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={headerAuthForm.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-toggle"
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="set-header-auth-form">
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceAuthMethods")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{/* Password row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-sm gap-x-2",
|
||||
hasPassword && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Key size="14" />
|
||||
<span>
|
||||
{t("resourcePasswordProtection", {
|
||||
status: hasPassword
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasPassword
|
||||
? () =>
|
||||
form.setValue(
|
||||
"password",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetPasswordOpen(true)
|
||||
}
|
||||
>
|
||||
{hasPassword
|
||||
? t("passwordRemove")
|
||||
: t("passwordAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pincode row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 text-sm",
|
||||
hasPincode && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Binary size="14" />
|
||||
<span>
|
||||
{t("resourcePincodeProtection", {
|
||||
status: hasPincode
|
||||
? t("enabled")
|
||||
: t("disabled")
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasPincode
|
||||
? () =>
|
||||
form.setValue(
|
||||
"pincode",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetPincodeOpen(true)
|
||||
}
|
||||
>
|
||||
{hasPincode
|
||||
? t("pincodeRemove")
|
||||
: t("pincodeAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header auth row */}
|
||||
<div className="flex items-center justify-between border rounded-md p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 text-sm",
|
||||
hasHeaderAuth && "text-green-500"
|
||||
)}
|
||||
>
|
||||
<Bot size="14" />
|
||||
<span>
|
||||
{hasHeaderAuth
|
||||
? t(
|
||||
"resourceHeaderAuthProtectionEnabled"
|
||||
)
|
||||
: t(
|
||||
"resourceHeaderAuthProtectionDisabled"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
onClick={
|
||||
hasHeaderAuth
|
||||
? () =>
|
||||
form.setValue(
|
||||
"headerAuth",
|
||||
null
|
||||
)
|
||||
: () =>
|
||||
setIsSetHeaderAuthOpen(
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
{hasHeaderAuth
|
||||
? t("headerAuthRemove")
|
||||
: t("headerAuthAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={readonly || isSubmitting}
|
||||
>
|
||||
{t("authMethodsSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/components/resource-policy/EditPolicyForm.tsx
Normal file
130
src/components/resource-policy/EditPolicyForm.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm";
|
||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||
import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm";
|
||||
import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm";
|
||||
import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
|
||||
|
||||
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
|
||||
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function EditPolicyForm({
|
||||
hidePolicyNameForm,
|
||||
readonly
|
||||
}: EditPolicyFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
// const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
);
|
||||
const isMaxmindASNAvailable = !!(
|
||||
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0
|
||||
);
|
||||
|
||||
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
|
||||
orgQueries.roles({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
|
||||
orgQueries.users({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||
orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||
})
|
||||
);
|
||||
|
||||
const allRoles = useMemo(
|
||||
() =>
|
||||
orgRoles
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin"),
|
||||
[orgRoles]
|
||||
);
|
||||
|
||||
const allUsers = useMemo(
|
||||
() =>
|
||||
orgUsers.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
})),
|
||||
[orgUsers]
|
||||
);
|
||||
|
||||
const allIdps = useMemo(() => {
|
||||
if (build === "saas") {
|
||||
if (isPaidUser(tierMatrix.orgOidc)) {
|
||||
return orgIdps.map((idp) => ({
|
||||
id: idp.idpId,
|
||||
text: idp.name
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name }));
|
||||
}
|
||||
return [];
|
||||
}, [orgIdps, isPaidUser]);
|
||||
|
||||
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && <EditPolicyNameSectionForm readonly={readonly} />}
|
||||
|
||||
<EditPolicyUsersRolesSectionForm
|
||||
allRoles={allRoles}
|
||||
allUsers={allUsers}
|
||||
allIdps={allIdps}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<EditPolicyAuthMethodsSectionForm readonly={readonly} />
|
||||
|
||||
<EditPolicyOtpEmailSectionForm
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<EditPolicyRulesSectionForm
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
155
src/components/resource-policy/EditPolicyNameSectionForm.tsx
Normal file
155
src/components/resource-policy/EditPolicyNameSectionForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { type ResourcePolicy } from "@server/db";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useActionState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// ─── PolicyNameSection ──────────────────────────────────────────────────
|
||||
|
||||
export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string()
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
name: policy.name
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<ResourcePolicy>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}`,
|
||||
{
|
||||
name: payload.name
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourcePolicyName")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyNameDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={readonly}
|
||||
placeholder={t(
|
||||
"resourcePolicyNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={readonly || isSubmitting}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
294
src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx
Normal file
294
src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import z from "zod";
|
||||
|
||||
import { createPolicySchema, type PolicyFormValues } from ".";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel
|
||||
} from "@app/components/ui/form";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
import { InfoIcon, Plus } from "lucide-react";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm, UseFormReturn, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
|
||||
// ─── PolicyOtpEmailSection ────────────────────────────────────────────────────
|
||||
|
||||
type PolicyOtpEmailSectionProps = {
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function EditPolicyOtpEmailSectionForm({
|
||||
emailEnabled,
|
||||
readonly
|
||||
}: PolicyOtpEmailSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
||||
emails: policy.emailWhiteList.map((email) => ({
|
||||
id: email.whiteListId.toString(),
|
||||
text: email.email
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const whitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(whitelistEnabled);
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/whitelist`,
|
||||
{
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails?.map((e) => e.text) ?? []
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!readonly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourcePolicyOtpEmailAdd")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center h-full size-full bg-muted rounded-md px-8 py-6 border-dashed text-sm">
|
||||
<p>{t("resourcePolicyOtpEmpty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("otpEmailTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("otpEmailTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={whitelistEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("emailWhitelistEnabled", val);
|
||||
}}
|
||||
disabled={readonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && emailEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text={t(
|
||||
"otpEmailWhitelistList"
|
||||
)}
|
||||
info={t(
|
||||
"otpEmailWhitelistListDescription"
|
||||
)}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size="sm"
|
||||
validateTag={(tag) => {
|
||||
return z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
t(
|
||||
"otpEmailErrorInvalid"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(tag)
|
||||
.success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"otpEmailEnter"
|
||||
)}
|
||||
tags={
|
||||
form.getValues()
|
||||
.emails ?? []
|
||||
}
|
||||
setTags={(newEmails) => {
|
||||
if (!readonly) {
|
||||
form.setValue(
|
||||
"emails",
|
||||
newEmails as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
readonly || isSubmitting || !emailEnabled
|
||||
}
|
||||
>
|
||||
{t("otpEmailWhitelistSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
1186
src/components/resource-policy/EditPolicyRulesSectionForm.tsx
Normal file
1186
src/components/resource-policy/EditPolicyRulesSectionForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPolicySchema } from ".";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
||||
|
||||
type PolicyUsersRolesSectionProps = {
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
allIdps: { id: number; text: string }[];
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export function EditPolicyUsersRolesSectionForm({
|
||||
allRoles,
|
||||
allUsers,
|
||||
allIdps,
|
||||
readonly
|
||||
}: PolicyUsersRolesSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
users: true,
|
||||
roles: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policy.roles.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
})),
|
||||
users: policy.users.map((user) => ({
|
||||
id: user.userId,
|
||||
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||
const selectedIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
|
||||
const isValid = await form.trigger();
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<AxiosResponse<{}>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}/access-control`,
|
||||
{
|
||||
sso: payload.sso,
|
||||
userIds: payload.users.map((user) => user.id),
|
||||
roleIds: payload.roles.map((role) => Number(role.id)),
|
||||
skipToIdpId: payload.skipToIdpId
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceUsersRoles")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourcePolicyUsersRolesDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={ssoEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
console.log(`form.setValue("sso", ${val})`);
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
disabled={readonly}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.roles
|
||||
}
|
||||
setTags={(newRoles) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.users
|
||||
}
|
||||
setTags={(newUsers) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
form.setValue(
|
||||
"skipToIdpId",
|
||||
null
|
||||
);
|
||||
} else {
|
||||
const id = parseInt(value);
|
||||
form.setValue(
|
||||
"skipToIdpId",
|
||||
id
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-1">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("none")}
|
||||
</SelectItem>
|
||||
{allIdps.map((idp) => (
|
||||
<SelectItem
|
||||
key={idp.id}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{idp.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"defaultIdentityProviderDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={readonly || isSubmitting}
|
||||
>
|
||||
{t("resourceUsersRolesSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
1918
src/components/resource-policy/ResourcePolicySubForms.tsx
Normal file
1918
src/components/resource-policy/ResourcePolicySubForms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
65
src/components/resource-policy/index.ts
Normal file
65
src/components/resource-policy/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// ─── Schemas & types ──────────────────────────────────────────────────────────
|
||||
|
||||
import z from "zod";
|
||||
|
||||
export const createPolicySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
sso: z.boolean().default(true),
|
||||
skipToIdpId: z.number().nullable().optional(),
|
||||
emailWhitelistEnabled: z.boolean().default(false),
|
||||
roles: z.array(z.object({ id: z.string(), text: z.string() })),
|
||||
users: z.array(z.object({ id: z.string(), text: z.string() })),
|
||||
emails: z.array(z.object({ id: z.string(), text: z.string() })),
|
||||
password: z
|
||||
.object({
|
||||
password: z.string().min(4).max(100)
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
pincode: z
|
||||
.object({
|
||||
pincode: z.string().regex(/^\d{6}$/)
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean().default(true)
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
});
|
||||
|
||||
export type PolicyFormValues = z.infer<typeof createPolicySchema>;
|
||||
|
||||
export const addRuleSchema = z.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
value: z.string(),
|
||||
priority: z.coerce.number<number>().int().optional()
|
||||
});
|
||||
|
||||
export type LocalRule = {
|
||||
ruleId: number;
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
match: string;
|
||||
value: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "../ui/popover";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||
import { TagList, TagListProps } from "./tag-list";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -47,7 +53,7 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (triggerContainerRef.current) {
|
||||
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||
|
||||
@@ -8,8 +8,11 @@ import type {
|
||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import type {
|
||||
GetResourceWhitelistResponse,
|
||||
GetResourcePoliciesResponse,
|
||||
ListResourceNamesResponse,
|
||||
ListResourcesResponse
|
||||
ListResourcesResponse,
|
||||
ListResourceRolesResponse,
|
||||
ListResourceUsersResponse
|
||||
} from "@server/routers/resource";
|
||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
@@ -33,6 +36,9 @@ import { remote } from "./api";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import { wait } from "./wait";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -508,6 +514,28 @@ export const orgQueries = {
|
||||
);
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
|
||||
policies: ({ orgId, name }: { orgId: string; name?: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: "10"
|
||||
});
|
||||
|
||||
if (name) {
|
||||
sp.set("query", name);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListResourcePoliciesResponse>
|
||||
>(`/org/${orgId}/resource-policies?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
|
||||
return res.data.data.policies;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -565,7 +593,7 @@ export const resourceQueries = {
|
||||
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSiteResourceUsersResponse>
|
||||
AxiosResponse<ListResourceUsersResponse>
|
||||
>(`/resource/${resourceId}/users`, { signal });
|
||||
return res.data.data.users;
|
||||
}
|
||||
@@ -575,7 +603,7 @@ export const resourceQueries = {
|
||||
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSiteResourceRolesResponse>
|
||||
AxiosResponse<ListResourceRolesResponse>
|
||||
>(`/resource/${resourceId}/roles`, { signal });
|
||||
|
||||
return res.data.data.roles;
|
||||
@@ -635,6 +663,17 @@ export const resourceQueries = {
|
||||
return res.data.data.whitelist;
|
||||
}
|
||||
}),
|
||||
policies: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<GetResourcePoliciesResponse>
|
||||
>(`/resource/${resourceId}/policies`, { signal });
|
||||
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
listNamesPerOrg: (orgId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES_NAMES", orgId] as const,
|
||||
|
||||
64
src/providers/ResourcePolicyProvider.tsx
Normal file
64
src/providers/ResourcePolicyProvider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
|
||||
interface ResourcePolicyProviderProps {
|
||||
children: React.ReactNode;
|
||||
policy: GetResourcePolicyResponse;
|
||||
}
|
||||
|
||||
export function ResourcePolicyProvider({
|
||||
children,
|
||||
policy: serverPolicy
|
||||
}: ResourcePolicyProviderProps) {
|
||||
const [policy, setPolicy] =
|
||||
useState<GetResourcePolicyResponse>(serverPolicy);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const updatePolicy = (
|
||||
updatedPolicy: Partial<GetResourcePolicyResponse>
|
||||
) => {
|
||||
if (!policy) {
|
||||
throw new Error(t("resourceErrorNoUpdate"));
|
||||
}
|
||||
|
||||
setPolicy((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
...updatedPolicy
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ResourcePolicyContext value={{ policy, updatePolicy }}>
|
||||
{children}
|
||||
</ResourcePolicyContext>
|
||||
);
|
||||
}
|
||||
|
||||
export type ResourcePolicyContextType = {
|
||||
policy: GetResourcePolicyResponse;
|
||||
updatePolicy: (updatedPolicy: Partial<GetResourcePolicyResponse>) => void;
|
||||
};
|
||||
|
||||
export const ResourcePolicyContext = createContext<
|
||||
ResourcePolicyContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useResourcePolicyContext() {
|
||||
const context = useContext(ResourcePolicyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useResourcePolicyContext must be used within a ResourcePolicyProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user