mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-17 21:01:53 +00:00
Merge branch 'dev' into refactor/standardize-clear-buttons
This commit is contained in:
40
src/components/AuthPageFooterNotices.tsx
Normal file
40
src/components/AuthPageFooterNotices.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function AuthPageFooterNotices() {
|
||||
const t = useTranslations();
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
type SingleSiteProps = {
|
||||
multiSite?: false;
|
||||
selectedSite: Selectedsite | null;
|
||||
onSiteChange: (site: Selectedsite | null) => void;
|
||||
};
|
||||
|
||||
type MultiSiteProps = {
|
||||
multiSite: true;
|
||||
selectedSites: Selectedsite[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps = {
|
||||
type BaseProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
destination: string;
|
||||
defaultPort: number;
|
||||
destinationPort: string;
|
||||
onDestinationChange: (v: string) => void;
|
||||
onDestinationPortChange: (v: string) => void;
|
||||
destinationField: Path<T>;
|
||||
destinationPortField: Path<T>;
|
||||
learnMoreHref?: string;
|
||||
} & (SingleSiteProps | MultiSiteProps);
|
||||
defaultPort: number;
|
||||
};
|
||||
|
||||
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite: true;
|
||||
sitesField: Path<T>;
|
||||
};
|
||||
|
||||
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite?: false;
|
||||
siteField: Path<T>;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
|
||||
| MultiSiteFormProps<T>
|
||||
| SingleSiteFormProps<T>;
|
||||
|
||||
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
props: BrowserGatewayTargetFormProps<T>
|
||||
) {
|
||||
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
|
||||
// Opt out of the React Compiler for this component.
|
||||
//
|
||||
// The parent (create page) shares a single `bgTargetForm` instance across
|
||||
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
|
||||
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
|
||||
// resource type changes. react-hook-form's Controller uses an external
|
||||
// subscription that the React Compiler cannot statically reason about, so
|
||||
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
|
||||
// the render prop and skip re-rendering the <Input> elements when their
|
||||
// bound form values change. The visible symptom is that typing into the
|
||||
// destination/port inputs updates form state but the input itself never
|
||||
// visually updates. The escape hatch is the canonical fix here.
|
||||
"use no memo";
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const siteSelector =
|
||||
props.multiSite === true ? (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
props.selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={props.selectedSites}
|
||||
onSelectionChange={props.onSitesChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.selectedSite?.name ?? t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={props.selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
props.onSiteChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
const sitesFieldName =
|
||||
props.multiSite === true ? props.sitesField : props.siteField;
|
||||
|
||||
// Subscribe to field values via useWatch and drive the controlled <Input>
|
||||
// elements from these values rather than from the `field.value` returned
|
||||
// by the Controller render prop. Combined with the "use no memo" directive
|
||||
// above, this makes the inputs reliably re-render when their bound form
|
||||
// values change.
|
||||
const watchedSites = useWatch({
|
||||
control: props.control,
|
||||
name: sitesFieldName
|
||||
});
|
||||
|
||||
const watchedDestination = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationField
|
||||
});
|
||||
|
||||
const watchedDestinationPort = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationPortField
|
||||
});
|
||||
|
||||
const showMultiSiteDisclaimer =
|
||||
props.multiSite === true &&
|
||||
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("sites")}
|
||||
</label>
|
||||
{siteSelector}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("destination")}
|
||||
</label>
|
||||
<Input
|
||||
value={props.destination}
|
||||
onChange={(e) =>
|
||||
props.onDestinationChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">{t("port")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={props.destinationPort}
|
||||
onChange={(e) =>
|
||||
props.onDestinationPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={sitesFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
props.multiSite === true
|
||||
? (
|
||||
field.value as Selectedsite[]
|
||||
)?.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
: !field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.multiSite === true
|
||||
? formatMultiSitesSelectorLabel(
|
||||
(field.value as Selectedsite[]) ??
|
||||
[],
|
||||
t
|
||||
)
|
||||
: ((
|
||||
field.value as Selectedsite | null
|
||||
)?.name ??
|
||||
t("siteSelect"))}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
{props.multiSite === true ? (
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={
|
||||
(field.value as Selectedsite[]) ??
|
||||
[]
|
||||
}
|
||||
onSelectionChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={
|
||||
field.value as Selectedsite | null
|
||||
}
|
||||
onSelectSite={(site) => {
|
||||
field.onChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("destination")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestination as
|
||||
| string
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationPortField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("port")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestinationPort as
|
||||
| string
|
||||
| number
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||
{showMultiSiteDisclaimer && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||
<a
|
||||
|
||||
@@ -408,12 +408,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
||||
? t("standaloneHcEditTitle")
|
||||
: t("standaloneHcCreateTitle");
|
||||
|
||||
const description =
|
||||
mode === "autoSave"
|
||||
? t("configureHealthCheckDescription", {
|
||||
target: (props as any).targetAddress
|
||||
})
|
||||
: t("standaloneHcDescription");
|
||||
const description = t("configureHealthCheckDescription");
|
||||
|
||||
const disableTabInputs = mode === "autoSave" && !watchedEnabled;
|
||||
const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp";
|
||||
|
||||
@@ -1813,9 +1813,9 @@ export function PrivateResourceForm({
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<StrategySelect<"standard" | "native">
|
||||
value={sshServerMode}
|
||||
options={[
|
||||
@@ -1870,9 +1870,9 @@ export function PrivateResourceForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pamMode"
|
||||
@@ -1965,9 +1965,9 @@ export function PrivateResourceForm({
|
||||
{/* Daemon Location (standard + push) */}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonMode"
|
||||
|
||||
@@ -363,7 +363,7 @@ export default function PrivateResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
if (resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ProxyResourcesBanner = () => {
|
||||
export const PublicResourcesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="proxy-resources-banner-dismissed"
|
||||
version={1}
|
||||
title={t("proxyResourcesBannerTitle")}
|
||||
title={t("publicResourcesBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("proxyResourcesBannerDescription")}
|
||||
description={t("publicResourcesBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyResourcesBanner;
|
||||
export default PublicResourcesBanner;
|
||||
@@ -127,7 +127,7 @@ const booleanSearchFilterSchema = z
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export default function ProxyResourcesTable({
|
||||
export default function PublicResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
pagination,
|
||||
@@ -44,7 +44,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
@@ -124,8 +124,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
@@ -727,29 +725,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<AuthPageFooterNotices />
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.mode!.toUpperCase()}
|
||||
{resource.ssl ? "HTTPS" : "HTTP"}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
21
src/components/ResourcePoliciesBanner.tsx
Normal file
21
src/components/ResourcePoliciesBanner.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ResourcePoliciesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="resource-policies-banner-dismissed"
|
||||
version={1}
|
||||
title={t("resourcePoliciesBannerTitle")}
|
||||
titleIcon={<Shield className="w-5 h-5 text-primary" />}
|
||||
description={t("resourcePoliciesBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcePoliciesBanner;
|
||||
@@ -8,12 +8,7 @@ import type {
|
||||
ListResourcePoliciesResponse
|
||||
} from "@server/routers/resource/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Waypoints
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -98,55 +93,50 @@ export function ResourcePoliciesTable({
|
||||
};
|
||||
|
||||
function ResourceListCell({
|
||||
orgId,
|
||||
resources
|
||||
}: {
|
||||
orgId: string;
|
||||
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 <span>-</span>;
|
||||
}
|
||||
|
||||
const countLabel = t("resourcePoliciesAttachedResourcesCount", {
|
||||
count: resources.length
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResources", {
|
||||
count: resources.length
|
||||
})}
|
||||
<span className="text-sm tabular-nums">
|
||||
{countLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-70">
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{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`}
|
||||
<DropdownMenuItem key={resource.resourceId} asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-4"
|
||||
>
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -182,7 +172,12 @@ export function ResourcePoliciesTable({
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <ResourceListCell resources={row.original.resources} />;
|
||||
return (
|
||||
<ResourceListCell
|
||||
orgId={row.original.orgId}
|
||||
resources={row.original.resources}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -205,7 +200,7 @@ export function ResourcePoliciesTable({
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
@@ -224,7 +219,7 @@ export function ResourcePoliciesTable({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
@@ -288,12 +283,13 @@ export function ResourcePoliciesTable({
|
||||
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/policies/resource/create`
|
||||
`/${orgId}/settings/policies/resources/public/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SettingsSubsectionHeader({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-0.5", className)}>{children}</div>;
|
||||
return <div className={cn("py-3 space-y-0.5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionTitle({
|
||||
@@ -80,9 +80,7 @@ export function SettingsSubsectionTitle({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
|
||||
);
|
||||
return <h3 className={cn("font-semibold", className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionDescription({
|
||||
|
||||
@@ -514,6 +514,16 @@ export default function SitesTable({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
|
||||
@@ -157,7 +157,7 @@ export function LabelsSelector({
|
||||
/>
|
||||
|
||||
<Select defaultValue={randomColor} name="color">
|
||||
<SelectTrigger className="w-18 [&_[data-name]]:hidden [&_[svg]]:hidden!">
|
||||
<SelectTrigger className="w-auto min-w-24">
|
||||
<SelectValue
|
||||
placeholder={t("selectColor")}
|
||||
/>
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"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"
|
||||
)}
|
||||
description={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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,11 @@ import { build } from "@server/build";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type PolicyFormValues, createPolicySchema } from ".";
|
||||
import {
|
||||
type PolicyFormValues,
|
||||
createPolicySchema,
|
||||
createPolicySchemaWithI18n
|
||||
} from ".";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgs, type ResourcePolicy } from "@server/db";
|
||||
@@ -37,10 +41,8 @@ import {
|
||||
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";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
@@ -78,8 +80,13 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const policySchema = useMemo(
|
||||
() => createPolicySchemaWithI18n(t, createPolicySchema),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm<PolicyFormValues>({
|
||||
resolver: zodResolver(createPolicySchema) as any,
|
||||
resolver: zodResolver(policySchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
sso: true,
|
||||
@@ -140,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
if (res && res.status === 201) {
|
||||
const niceId = res.data.data.niceId;
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/policies/resource/${niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}/general`
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
@@ -220,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -230,12 +237,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"resourcePolicyNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -245,18 +247,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<CreatePolicyUsersRolesSectionForm
|
||||
<PolicyAuthStackSection
|
||||
mode="create"
|
||||
form={form}
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
allRoles={allRoles}
|
||||
allUsers={allUsers}
|
||||
allIdps={allIdps}
|
||||
/>
|
||||
<CreatePolicyAuthMethodsSectionForm form={form} />
|
||||
<CreatePolicyOtpEmailSectionForm
|
||||
form={form}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
/>
|
||||
<CreatePolicyRulesSectionForm
|
||||
<PolicyAccessRulesSection
|
||||
mode="create"
|
||||
form={form}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
"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"
|
||||
)}
|
||||
description={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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,44 +10,34 @@ import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
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";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
|
||||
// ─── EditPolicyForm ─────────────────────────────────────────────────────────
|
||||
export type EditPolicyFormSection = "general" | "authentication" | "rules";
|
||||
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
section?: EditPolicyFormSection;
|
||||
};
|
||||
|
||||
export function EditPolicyForm({
|
||||
hidePolicyNameForm,
|
||||
readonly,
|
||||
resourceId
|
||||
resourceId,
|
||||
section
|
||||
}: 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();
|
||||
|
||||
// In overlay mode (resourceId provided), policy-level sections are locked.
|
||||
// Rules and users/roles sections handle their own hybrid logic via resourceId.
|
||||
const isOverlay = resourceId !== undefined;
|
||||
const policyLevelReadonly = readonly || isOverlay;
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
@@ -81,32 +71,48 @@ export function EditPolicyForm({
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const authSection = (
|
||||
<PolicyAuthStackSection
|
||||
mode="edit"
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
|
||||
const rulesSection = (
|
||||
<PolicyAccessRulesSection
|
||||
mode="edit"
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (section === "general") {
|
||||
return <EditPolicyNameSectionForm readonly={readonly} />;
|
||||
}
|
||||
|
||||
if (section === "authentication") {
|
||||
return authSection;
|
||||
}
|
||||
|
||||
if (section === "rules") {
|
||||
return rulesSection;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && (
|
||||
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
|
||||
{!hidePolicyNameForm && !isOverlay && (
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
)}
|
||||
|
||||
<EditPolicyUsersRolesSectionForm
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
{authSection}
|
||||
|
||||
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
|
||||
|
||||
<EditPolicyOtpEmailSectionForm
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={policyLevelReadonly}
|
||||
/>
|
||||
|
||||
<EditPolicyRulesSectionForm
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
{rulesSection}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -40,21 +41,28 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
// ─── PolicyNameSection ──────────────────────────────────────────────────
|
||||
|
||||
export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) {
|
||||
const PolicyNameFormSchema = z.object({
|
||||
name: z.string(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
});
|
||||
|
||||
export function EditPolicyNameSectionForm({
|
||||
readonly
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const { policy, updatePolicy } = useResourcePolicyContext();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string()
|
||||
})
|
||||
),
|
||||
resolver: zodResolver(PolicyNameFormSchema),
|
||||
defaultValues: {
|
||||
name: policy.name
|
||||
name: policy.name,
|
||||
niceId: policy.niceId || ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,7 +81,8 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
.put<AxiosResponse<ResourcePolicy>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}`,
|
||||
{
|
||||
name: payload.name
|
||||
name: payload.name,
|
||||
niceId: payload.niceId
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -88,10 +97,22 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
updatePolicy({
|
||||
name: payload.name,
|
||||
niceId: payload.niceId
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
|
||||
if (payload.niceId && payload.niceId !== policy.niceId) {
|
||||
router.replace(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -116,7 +137,7 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<SettingsSectionForm variant="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -136,6 +157,26 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("identifier")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={readonly}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,530 +0,0 @@
|
||||
"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 {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
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 { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
||||
|
||||
type PolicyUsersRolesSectionProps = {
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
export function EditPolicyUsersRolesSectionForm({
|
||||
orgId,
|
||||
allIdps,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyUsersRolesSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
// ── Resource overlay: fetch resource-specific roles & users ──────────────
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
const { data: resourceRolesData } = useQuery({
|
||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const { data: resourceUsersData } = useQuery({
|
||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
// IDs from the policy (locked — cannot be removed)
|
||||
const policyRoleLockedIds = useMemo(
|
||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserLockedIds = useMemo(
|
||||
() => new Set(policy.users.map((u) => u.userId)),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Policy entries mapped to selector format
|
||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: false
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserItems = useMemo(
|
||||
() =>
|
||||
policy.users.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
})),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Track the initial resource-specific roles/users for diffing on save
|
||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Combined selected roles/users (policy + resource-specific)
|
||||
const [combinedRoles, setCombinedRoles] =
|
||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||
if (!resourceRolesData) return;
|
||||
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
}));
|
||||
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles(
|
||||
[...policyRoleItems, ...resourceSpecific].filter(
|
||||
(role) => !role.isAdmin
|
||||
)
|
||||
);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRolesData,
|
||||
resourceRolesInitialized,
|
||||
policyRoleItems,
|
||||
policyRoleLockedIds
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||
if (!resourceUsersData) return;
|
||||
|
||||
const resourceSpecific = resourceUsersData
|
||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||
.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
initialResourceUserIdsRef.current = new Set(
|
||||
resourceSpecific.map((u) => u.id)
|
||||
);
|
||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||
setResourceUsersInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceUsersData,
|
||||
resourceUsersInitialized,
|
||||
policyUserItems,
|
||||
policyUserLockedIds
|
||||
]);
|
||||
|
||||
// ── Standard policy form (non-overlay) ──────────────────────────────────
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
users: true,
|
||||
roles: true
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems
|
||||
}
|
||||
});
|
||||
|
||||
const ssoEnabled = useWatch({ control: form.control, name: "sso" });
|
||||
const selectedIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlay();
|
||||
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")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlay() {
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
// Compute which roles/users are resource-specific (non-locked)
|
||||
const currentResourceRoleIds = combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => Number(r.id));
|
||||
const currentResourceUserIds = combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id);
|
||||
|
||||
// Use bulk-set endpoints (session-authenticated) which replace
|
||||
// all resource-specific roles/users in one call
|
||||
await Promise.all([
|
||||
api.post(`/resource/${resourceId}/roles`, {
|
||||
roleIds: currentResourceRoleIds
|
||||
}),
|
||||
api.post(`/resource/${resourceId}/users`, {
|
||||
userIds: currentResourceUserIds
|
||||
})
|
||||
]);
|
||||
|
||||
// Update refs to reflect new state
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
currentResourceRoleIds.map(String)
|
||||
);
|
||||
initialResourceUserIdsRef.current = new Set(currentResourceUserIds);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsSavingOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isResourceOverlay &&
|
||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||
|
||||
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) => {
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
disabled={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={combinedRoles.filter(
|
||||
(role) => !role.isAdmin
|
||||
)}
|
||||
onSelectRoles={(roles) => {
|
||||
setCombinedRoles(
|
||||
roles
|
||||
.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isAdmin
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={
|
||||
policyRoleLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
roles
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
roles
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
combinedUsers
|
||||
}
|
||||
onSelectUsers={
|
||||
setCombinedUsers
|
||||
}
|
||||
disabled={isLoading}
|
||||
lockedIds={
|
||||
policyUserLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
users
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
users
|
||||
)
|
||||
}
|
||||
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 || isResourceOverlay}
|
||||
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 || isSavingOverlay}
|
||||
disabled={
|
||||
readonly ||
|
||||
isSubmitting ||
|
||||
isSavingOverlay ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{t("resourceUsersRolesSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
29
src/components/resource-policy/PolicyAccessRulesIntro.tsx
Normal file
29
src/components/resource-policy/PolicyAccessRulesIntro.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type PolicyAccessRulesIntroProps = {
|
||||
rulesEnabled: boolean;
|
||||
onRulesEnabledChange: (enabled: boolean) => void;
|
||||
disableToggle?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAccessRulesIntro({
|
||||
rulesEnabled,
|
||||
onRulesEnabledChange,
|
||||
disableToggle
|
||||
}: PolicyAccessRulesIntroProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t("rulesEnable")}
|
||||
description={t("policyAccessRulesEnableDescription")}
|
||||
checked={rulesEnabled}
|
||||
disabled={disableToggle}
|
||||
onCheckedChange={onRulesEnabledChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
561
src/components/resource-policy/PolicyAccessRulesSection.tsx
Normal file
561
src/components/resource-policy/PolicyAccessRulesSection.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
createPolicyRulesSectionSchema,
|
||||
validatePolicyRulesForSave,
|
||||
type PolicyFormValues
|
||||
} from ".";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro";
|
||||
import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import {
|
||||
createEmptyRule,
|
||||
prependEmptyRule,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
// ─── PolicyRulesSection ───────────────────────────────────────────────────────
|
||||
|
||||
type PolicyAccessRulesSectionEditProps = {
|
||||
mode: "edit";
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type PolicyAccessRulesSectionCreateProps = {
|
||||
mode: "create";
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
};
|
||||
|
||||
export type PolicyAccessRulesSectionProps =
|
||||
| PolicyAccessRulesSectionEditProps
|
||||
| PolicyAccessRulesSectionCreateProps;
|
||||
|
||||
export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) {
|
||||
if (props.mode === "create") {
|
||||
return <PolicyAccessRulesSectionCreate {...props} />;
|
||||
}
|
||||
return <PolicyAccessRulesSectionEdit {...props} />;
|
||||
}
|
||||
|
||||
type PolicyAccessRulesSectionLayoutProps = {
|
||||
rulesEnabled: boolean;
|
||||
onRulesEnabledChange: (enabled: boolean) => void;
|
||||
disableToggle?: boolean;
|
||||
rules: PolicyAccessRule[];
|
||||
onRulesChange: (rules: PolicyAccessRule[]) => void;
|
||||
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
|
||||
removeRule: (ruleId: number) => void;
|
||||
readonly?: boolean;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function PolicyAccessRulesSectionLayout({
|
||||
rulesEnabled,
|
||||
onRulesEnabledChange,
|
||||
disableToggle,
|
||||
rules,
|
||||
onRulesChange,
|
||||
updateRule,
|
||||
removeRule,
|
||||
readonly,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
resourceOverlayMode,
|
||||
footer
|
||||
}: PolicyAccessRulesSectionLayoutProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const addEmptyRule = useCallback(() => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(prependEmptyRule(rules));
|
||||
return;
|
||||
}
|
||||
onRulesChange([...rules, createEmptyRule(rules)]);
|
||||
}, [rules, onRulesChange, resourceOverlayMode]);
|
||||
|
||||
const addRuleButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={addEmptyRule}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const hasRules = rules.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAccessRulesTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rulesResourceDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-4">
|
||||
{resourceOverlayMode && (
|
||||
<SharedPolicyResourceNotice section="rules" />
|
||||
)}
|
||||
<PolicyAccessRulesIntro
|
||||
rulesEnabled={rulesEnabled}
|
||||
onRulesEnabledChange={onRulesEnabledChange}
|
||||
disableToggle={disableToggle}
|
||||
/>
|
||||
|
||||
{rulesEnabled && (
|
||||
<>
|
||||
<PolicyAccessRulesTable
|
||||
rules={rules}
|
||||
onRulesChange={onRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
includeRegionMatch
|
||||
markUpdatedOnReorder
|
||||
resourceOverlayMode={resourceOverlayMode}
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
{footer}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function usePolicyAccessRulesFormSync(
|
||||
form: UseFormReturn<{
|
||||
applyRules: boolean;
|
||||
rules: PolicyFormValues["rules"];
|
||||
}>
|
||||
) {
|
||||
const syncFormRules = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
form.setValue(
|
||||
"rules",
|
||||
updatedRules.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const updateRulesState = useCallback(
|
||||
(
|
||||
setRules: React.Dispatch<React.SetStateAction<PolicyAccessRule[]>>,
|
||||
updatedRules: PolicyAccessRule[]
|
||||
) => {
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[syncFormRules]
|
||||
);
|
||||
|
||||
return { syncFormRules, updateRulesState };
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionEdit({
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyAccessRulesSectionEditProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
const { data: resourceRulesData } = useQuery({
|
||||
...resourceQueries.resourceRules({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const deletedResourceRuleIdsRef = useRef<Set<number>>(new Set());
|
||||
const [resourceRulesInitialized, setResourceRulesInitialized] =
|
||||
useState(false);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: policy.applyRules,
|
||||
rules: policy.rules
|
||||
}
|
||||
});
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>(
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
|
||||
);
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRulesInitialized) return;
|
||||
if (!resourceRulesData) return;
|
||||
|
||||
const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId));
|
||||
const resourceSpecific: PolicyAccessRule[] = resourceRulesData
|
||||
.filter((r) => !policyRuleIds.has(r.ruleId))
|
||||
.map((r) => ({
|
||||
ruleId: r.ruleId,
|
||||
action: r.action as "ACCEPT" | "DROP" | "PASS",
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled,
|
||||
fromPolicy: false
|
||||
}));
|
||||
|
||||
setRules([
|
||||
...resourceSpecific,
|
||||
...policy.rules.map((r) => ({ ...r, fromPolicy: true }))
|
||||
]);
|
||||
setResourceRulesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRulesData,
|
||||
resourceRulesInitialized,
|
||||
policy.rules
|
||||
]);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const rule = rules.find((r) => r.ruleId === ruleId);
|
||||
if (!rule || rule.fromPolicy) return;
|
||||
if (isResourceOverlay && !rule.new) {
|
||||
deletedResourceRuleIdsRef.current.add(ruleId);
|
||||
}
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, handleRulesChange, isResourceOverlay]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function saveRules() {
|
||||
if (readonly) return;
|
||||
|
||||
const applyRules = form.getValues("applyRules") ?? false;
|
||||
const rulesToValidate = isResourceOverlay
|
||||
? rules.filter((rule) => !rule.fromPolicy)
|
||||
: rules;
|
||||
const rulesPayload = rulesToValidate.map(
|
||||
({ action, match, value, priority, enabled }) => ({
|
||||
action,
|
||||
match,
|
||||
value,
|
||||
priority,
|
||||
enabled
|
||||
})
|
||||
);
|
||||
const validation = validatePolicyRulesForSave(
|
||||
t,
|
||||
rulesPayload,
|
||||
applyRules
|
||||
);
|
||||
if (!validation.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validation.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlayRules();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = {
|
||||
applyRules,
|
||||
rules: rulesPayload
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<{}>
|
||||
>(`/resource-policy/${policy.resourcePolicyId}/rules`, payload)
|
||||
.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")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlayRules() {
|
||||
try {
|
||||
const newRules = rules.filter((r) => !r.fromPolicy && r.new);
|
||||
const updatedRules = rules.filter(
|
||||
(r) => !r.fromPolicy && !r.new && r.updated
|
||||
);
|
||||
const deletedIds = [...deletedResourceRuleIdsRef.current];
|
||||
|
||||
await Promise.all([
|
||||
...newRules.map((r) =>
|
||||
api.put(`/resource/${resourceId}/rule`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...updatedRules.map((r) =>
|
||||
api.post(`/resource/${resourceId}/rule/${r.ruleId}`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...deletedIds.map((id) =>
|
||||
api.delete(`/resource/${resourceId}/rule/${id}`)
|
||||
)
|
||||
]);
|
||||
|
||||
deletedResourceRuleIdsRef.current = new Set();
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disableToggle={readonly || isResourceOverlay}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
readonly={readonly}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
resourceOverlayMode={isResourceOverlay}
|
||||
footer={
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => startTransition(() => saveRules())}
|
||||
loading={isPending}
|
||||
disabled={readonly || isPending}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyAccessRulesSectionCreate({
|
||||
form: parentForm,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable
|
||||
}: PolicyAccessRulesSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [rules, setRules] = useState<PolicyAccessRule[]>([]);
|
||||
|
||||
const rulesFormSchema = useMemo(
|
||||
() => createPolicyRulesSectionSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(rulesFormSchema),
|
||||
defaultValues: {
|
||||
applyRules: false,
|
||||
rules: []
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((values) => {
|
||||
parentForm.setValue("applyRules", values.applyRules as boolean);
|
||||
parentForm.setValue(
|
||||
"rules",
|
||||
values.rules as PolicyFormValues["rules"]
|
||||
);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, parentForm]);
|
||||
|
||||
const rulesEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const { updateRulesState } = usePolicyAccessRulesFormSync(form);
|
||||
|
||||
const handleRulesChange = useCallback(
|
||||
(updatedRules: PolicyAccessRule[]) => {
|
||||
updateRulesState(setRules, updatedRules);
|
||||
},
|
||||
[updateRulesState]
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId));
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
function updateRule(ruleId: number, data: Partial<PolicyAccessRule>) {
|
||||
handleRulesChange(
|
||||
rules.map((rule) =>
|
||||
rule.ruleId === ruleId
|
||||
? { ...rule, ...data, updated: true }
|
||||
: rule
|
||||
)
|
||||
);
|
||||
},
|
||||
[rules, handleRulesChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<PolicyAccessRulesSectionLayout
|
||||
rulesEnabled={Boolean(rulesEnabled)}
|
||||
onRulesEnabledChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
rules={rules}
|
||||
onRulesChange={handleRulesChange}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
933
src/components/resource-policy/PolicyAccessRulesTable.tsx
Normal file
933
src/components/resource-policy/PolicyAccessRulesTable.tsx
Normal file
@@ -0,0 +1,933 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { MAJOR_ASNS } from "@server/db/asns";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
LockIcon
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import {
|
||||
validatePolicyRulePriority,
|
||||
validatePolicyRuleValue
|
||||
} from "./policy-access-rule-validation";
|
||||
import {
|
||||
buildDisplayPrioritiesForResourceOverlay,
|
||||
reorderPolicyRules,
|
||||
reorderResourceOverlayRules,
|
||||
setResourceRuleDisplayPriority,
|
||||
sortPolicyRulesByPriority,
|
||||
sortPolicyRulesForResourceOverlay,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
|
||||
export type PolicyAccessRulesTableProps = {
|
||||
rules: PolicyAccessRule[];
|
||||
onRulesChange: (rules: PolicyAccessRule[]) => void;
|
||||
updateRule: (ruleId: number, data: Partial<PolicyAccessRule>) => void;
|
||||
removeRule: (ruleId: number) => void;
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
emptyStateAction: ReactNode;
|
||||
readonly?: boolean;
|
||||
includeRegionMatch?: boolean;
|
||||
markUpdatedOnReorder?: boolean;
|
||||
resourceOverlayMode?: boolean;
|
||||
isRuleDraggable?: (rule: PolicyAccessRule) => boolean;
|
||||
isRuleLocked?: (rule: PolicyAccessRule) => boolean;
|
||||
};
|
||||
|
||||
function getColumnClassName(columnId: string) {
|
||||
if (columnId === "actions") {
|
||||
return "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right";
|
||||
}
|
||||
if (columnId === "dragHandle") {
|
||||
return "w-8 max-w-8 px-2";
|
||||
}
|
||||
if (columnId === "priority") {
|
||||
return "w-24 max-w-24";
|
||||
}
|
||||
if (columnId === "action") {
|
||||
return "w-42 max-w-42";
|
||||
}
|
||||
if (columnId === "match") {
|
||||
return "w-36 max-w-36";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function PolicyAccessRulesTable({
|
||||
rules,
|
||||
onRulesChange,
|
||||
updateRule,
|
||||
removeRule,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
emptyStateAction,
|
||||
readonly = false,
|
||||
includeRegionMatch = false,
|
||||
markUpdatedOnReorder = false,
|
||||
resourceOverlayMode = false,
|
||||
isRuleDraggable: isRuleDraggableProp,
|
||||
isRuleLocked: isRuleLockedProp
|
||||
}: PolicyAccessRulesTableProps) {
|
||||
const t = useTranslations();
|
||||
const [draggedRuleId, setDraggedRuleId] = useState<number | null>(null);
|
||||
const [dragOverRuleId, setDragOverRuleId] = useState<number | null>(null);
|
||||
|
||||
const isRuleLocked = useCallback(
|
||||
(rule: PolicyAccessRule) =>
|
||||
isRuleLockedProp
|
||||
? isRuleLockedProp(rule)
|
||||
: Boolean(rule.fromPolicy),
|
||||
[isRuleLockedProp]
|
||||
);
|
||||
|
||||
const isRuleDraggable = useCallback(
|
||||
(rule: PolicyAccessRule) =>
|
||||
isRuleDraggableProp
|
||||
? isRuleDraggableProp(rule)
|
||||
: !readonly && !isRuleLocked(rule),
|
||||
[isRuleDraggableProp, isRuleLocked, readonly]
|
||||
);
|
||||
|
||||
const sortedRules = useMemo(
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? sortPolicyRulesForResourceOverlay(rules)
|
||||
: sortPolicyRulesByPriority(rules),
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const displayPriorities = useMemo(
|
||||
() =>
|
||||
resourceOverlayMode
|
||||
? buildDisplayPrioritiesForResourceOverlay(rules)
|
||||
: null,
|
||||
[rules, resourceOverlayMode]
|
||||
);
|
||||
|
||||
const resourceRuleCount = useMemo(
|
||||
() => rules.filter((rule) => !rule.fromPolicy).length,
|
||||
[rules]
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(fromRuleId: number, toRuleId: number) => {
|
||||
if (resourceOverlayMode) {
|
||||
onRulesChange(
|
||||
reorderResourceOverlayRules(rules, fromRuleId, toRuleId, {
|
||||
markUpdated: markUpdatedOnReorder
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
const toIndex = sortedRules.findIndex(
|
||||
(rule) => rule.ruleId === toRuleId
|
||||
);
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reordered = reorderPolicyRules(
|
||||
sortedRules,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
{ markUpdated: markUpdatedOnReorder }
|
||||
);
|
||||
onRulesChange(reordered);
|
||||
},
|
||||
[
|
||||
rules,
|
||||
sortedRules,
|
||||
onRulesChange,
|
||||
markUpdatedOnReorder,
|
||||
resourceOverlayMode
|
||||
]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((ruleId: number, e: DragEvent) => {
|
||||
setDraggedRuleId(ruleId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedRuleId(null);
|
||||
setDragOverRuleId(null);
|
||||
}, []);
|
||||
|
||||
const RuleAction = useMemo(
|
||||
() => ({
|
||||
ACCEPT: t("alwaysAllow"),
|
||||
DROP: t("alwaysDeny"),
|
||||
PASS: t("passToAuth")
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const RuleMatch = useMemo(
|
||||
() => ({
|
||||
PATH: t("path"),
|
||||
IP: "IP",
|
||||
CIDR: t("ipAddressRange"),
|
||||
COUNTRY: t("country"),
|
||||
ASN: "ASN",
|
||||
REGION: t("region")
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<PolicyAccessRule>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "dragHandle",
|
||||
size: 32,
|
||||
maxSize: 32,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
isRuleDraggable(row.original) ? (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
tabIndex={-1}
|
||||
aria-label={t("rulesReorderDragHandle")}
|
||||
className="flex items-center justify-center text-muted-foreground cursor-grab active:cursor-grabbing"
|
||||
onDragStart={(e) =>
|
||||
handleDragStart(row.original.ruleId, e)
|
||||
}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
) : null
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
size: 96,
|
||||
maxSize: 96,
|
||||
header: ({ column }) => (
|
||||
<div className="p-3">
|
||||
{resourceOverlayMode ? (
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("rulesPriority")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-medium text-muted-foreground hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rulesPriority")}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const displayPriority = resourceOverlayMode
|
||||
? (displayPriorities?.get(row.original.ruleId) ??
|
||||
row.original.priority)
|
||||
: row.original.priority;
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={`${row.original.ruleId}-${displayPriority}`}
|
||||
defaultValue={displayPriority}
|
||||
className="w-full min-w-0"
|
||||
type="number"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRulePriority(
|
||||
t,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resourceOverlayMode) {
|
||||
if (
|
||||
validated.data > resourceRuleCount ||
|
||||
validated.data < 1
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorInvalidPriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorInvalidPriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateDisplayPriority = rules.some(
|
||||
(rule) =>
|
||||
!rule.fromPolicy &&
|
||||
rule.ruleId !==
|
||||
row.original.ruleId &&
|
||||
displayPriorities?.get(
|
||||
rule.ruleId
|
||||
) === validated.data
|
||||
);
|
||||
if (duplicateDisplayPriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t(
|
||||
"rulesErrorDuplicatePriority"
|
||||
),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (validated.data === displayPriority) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRulesChange(
|
||||
setResourceRuleDisplayPriority(
|
||||
rules,
|
||||
row.original.ruleId,
|
||||
validated.data,
|
||||
{
|
||||
markUpdated:
|
||||
markUpdatedOnReorder
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatePriority = rules.some(
|
||||
(rule) =>
|
||||
rule.ruleId !== row.original.ruleId &&
|
||||
rule.priority === validated.data
|
||||
);
|
||||
if (duplicatePriority) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorDuplicatePriority"),
|
||||
description: t(
|
||||
"rulesErrorDuplicatePriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
size: 160,
|
||||
maxSize: 160,
|
||||
header: () => <span className="p-3">{t("rulesAction")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
|
||||
updateRule(row.original.ruleId, {
|
||||
action: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACCEPT">
|
||||
{RuleAction.ACCEPT}
|
||||
</SelectItem>
|
||||
<SelectItem value="DROP">
|
||||
{RuleAction.DROP}
|
||||
</SelectItem>
|
||||
<SelectItem value="PASS">
|
||||
{RuleAction.PASS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
size: 144,
|
||||
maxSize: 144,
|
||||
header: () => (
|
||||
<span className="p-3">{t("rulesMatchType")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onValueChange={(
|
||||
value:
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
match: value,
|
||||
value:
|
||||
value === "COUNTRY"
|
||||
? "US"
|
||||
: value === "ASN"
|
||||
? "AS15169"
|
||||
: value === "REGION"
|
||||
? "021"
|
||||
: row.original.value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">
|
||||
{RuleMatch.PATH}
|
||||
</SelectItem>
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">
|
||||
{RuleMatch.CIDR}
|
||||
</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="COUNTRY">
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
)}
|
||||
{includeRegionMatch && isMaxmindAvailable && (
|
||||
<SelectItem value="REGION">
|
||||
{RuleMatch.REGION}
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{RuleMatch.ASN}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: () => <span className="p-3">{t("value")}</span>,
|
||||
cell: ({ row }) =>
|
||||
row.original.match === "COUNTRY" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
? COUNTRIES.find(
|
||||
(c) =>
|
||||
c.code === row.original.value
|
||||
)?.name +
|
||||
" (" +
|
||||
row.original.value +
|
||||
")"
|
||||
: t("selectCountry")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchCountries")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noCountryFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{COUNTRIES.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
value={country.name}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{
|
||||
value: country.code
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{country.name} (
|
||||
{country.code})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : row.original.match === "ASN" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
? (() => {
|
||||
const found = MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
})()
|
||||
: "Select ASN"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search ASNs or enter custom..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No ASN found. Enter a custom ASN
|
||||
below.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{MAJOR_ASNS.map((asn) => (
|
||||
<CommandItem
|
||||
key={asn.code}
|
||||
value={
|
||||
asn.name +
|
||||
" " +
|
||||
asn.code
|
||||
}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: asn.code }
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${row.original.value === asn.code ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{asn.name} ({asn.code})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<div className="border-t p-2">
|
||||
<Input
|
||||
placeholder="Enter custom ASN (e.g., AS15169)"
|
||||
defaultValue={
|
||||
!MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
)
|
||||
? row.original.value
|
||||
: ""
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value =
|
||||
e.currentTarget.value
|
||||
.toUpperCase()
|
||||
.replace(/^AS/, "");
|
||||
if (/^\d+$/.test(value)) {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: "AS" + value }
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : row.original.match === "REGION" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly || isRuleLocked(row.original)
|
||||
}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{(() => {
|
||||
const regionName = getRegionNameById(
|
||||
row.original.value
|
||||
);
|
||||
if (!regionName) {
|
||||
return t("selectRegion");
|
||||
}
|
||||
return `${t(regionName)} (${row.original.value})`;
|
||||
})()}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("searchRegions")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("noRegionFound")}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup
|
||||
key={continent.id}
|
||||
heading={t(continent.name)}
|
||||
>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
t(continent.name),
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{
|
||||
value: continent.id
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original
|
||||
.value ===
|
||||
continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(continent.name)} (
|
||||
{continent.id})
|
||||
</CommandItem>
|
||||
{continent.includes.map(
|
||||
(subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(
|
||||
subregion.name
|
||||
),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() =>
|
||||
updateRule(
|
||||
row.original
|
||||
.ruleId,
|
||||
{
|
||||
value: subregion.id
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original
|
||||
.value ===
|
||||
subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)}{" "}
|
||||
({subregion.id})
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="w-full min-w-0"
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onBlur={(e) => {
|
||||
const validated = validatePolicyRuleValue(
|
||||
t,
|
||||
row.original.match,
|
||||
e.target.value
|
||||
);
|
||||
if (!validated.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
...validated.toast
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
value: validated.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center w-full">
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
disabled={readonly || isRuleLocked(row.original)}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
enabled: val
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{isRuleLocked(row.original) ? (
|
||||
<LockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[
|
||||
t,
|
||||
RuleAction,
|
||||
RuleMatch,
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
includeRegionMatch,
|
||||
updateRule,
|
||||
onRulesChange,
|
||||
removeRule,
|
||||
readonly,
|
||||
rules,
|
||||
resourceOverlayMode,
|
||||
displayPriorities,
|
||||
resourceRuleCount,
|
||||
markUpdatedOnReorder,
|
||||
isRuleDraggable,
|
||||
isRuleLocked,
|
||||
handleDragStart,
|
||||
handleDragEnd
|
||||
]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: { pagination: { pageIndex: 0, pageSize: 1000 } }
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnId = header.column.id;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getColumnClassName(columnId)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const rule = row.original;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId
|
||||
) {
|
||||
setDragOverRuleId(rule.ruleId);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedRuleId !== null &&
|
||||
draggedRuleId !== rule.ruleId &&
|
||||
isRuleDraggable(rule)
|
||||
) {
|
||||
handleReorder(
|
||||
draggedRuleId,
|
||||
rule.ruleId
|
||||
);
|
||||
}
|
||||
setDraggedRuleId(null);
|
||||
setDragOverRuleId(null);
|
||||
}}
|
||||
className={cn(
|
||||
draggedRuleId === rule.ruleId &&
|
||||
"opacity-50",
|
||||
dragOverRuleId === rule.ruleId &&
|
||||
"border-t border-primary"
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getColumnClassName(
|
||||
columnId
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DataTableEmptyState
|
||||
colSpan={columns.length}
|
||||
message={t("rulesNoOne")}
|
||||
action={emptyStateAction}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
492
src/components/resource-policy/PolicyAuthMethodCredenzas.tsx
Normal file
492
src/components/resource-policy/PolicyAuthMethodCredenzas.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
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,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
setHeaderAuthSchema,
|
||||
setPasswordSchema,
|
||||
setPincodeSchema
|
||||
} from "./policy-auth-method-id";
|
||||
|
||||
type CredenzaShellProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
formId: string;
|
||||
submitLabel: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function CredenzaShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
formId,
|
||||
submitLabel,
|
||||
children
|
||||
}: CredenzaShellProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{title}</CredenzaTitle>
|
||||
<CredenzaDescription>{description}</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>{children}</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form={formId}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
type PasscodeCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultPassword?: string;
|
||||
existingConfigured?: boolean;
|
||||
onSave: (password: string) => void;
|
||||
};
|
||||
|
||||
export function PasscodeCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultPassword = "",
|
||||
existingConfigured,
|
||||
onSave
|
||||
}: PasscodeCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setPasswordSchema),
|
||||
defaultValues: { password: defaultPassword }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ password: defaultPassword });
|
||||
}
|
||||
}, [open, defaultPassword, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourcePasswordSetupTitle")}
|
||||
description={t("resourcePasswordSetupTitleDescription")}
|
||||
formId="policy-passcode-form"
|
||||
submitLabel={t("policyAuthSetPasscode")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-passcode-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data.password);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthPasscodeTitle")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
placeholder={
|
||||
existingConfigured
|
||||
? "••••••••"
|
||||
: undefined
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type PincodeCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultPincode?: string;
|
||||
onSave: (pincode: string) => void;
|
||||
};
|
||||
|
||||
export function PincodeCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultPincode = "",
|
||||
onSave
|
||||
}: PincodeCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setPincodeSchema),
|
||||
defaultValues: { pincode: defaultPincode }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ pincode: defaultPincode });
|
||||
}
|
||||
}, [open, defaultPincode, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourcePincodeSetupTitle")}
|
||||
description={t("resourcePincodeSetupTitleDescription")}
|
||||
formId="policy-pincode-form"
|
||||
submitLabel={t("policyAuthSetPincode")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-pincode-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data.pincode);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("resourcePincode")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
obscured
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderAuthCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultValues?: {
|
||||
user: string;
|
||||
password: string;
|
||||
extendedCompatibility: boolean;
|
||||
};
|
||||
existingConfigured?: boolean;
|
||||
onSave: (values: z.infer<typeof setHeaderAuthSchema>) => void;
|
||||
};
|
||||
|
||||
export function HeaderAuthCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultValues,
|
||||
existingConfigured,
|
||||
onSave
|
||||
}: HeaderAuthCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(setHeaderAuthSchema),
|
||||
defaultValues: {
|
||||
user: "",
|
||||
password: "",
|
||||
extendedCompatibility: true,
|
||||
...defaultValues
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
user: defaultValues?.user ?? "",
|
||||
password: defaultValues?.password ?? "",
|
||||
extendedCompatibility:
|
||||
defaultValues?.extendedCompatibility ?? true
|
||||
});
|
||||
}
|
||||
}, [open, defaultValues, form]);
|
||||
|
||||
return (
|
||||
<CredenzaShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("resourceHeaderAuthSetupTitle")}
|
||||
description={t("resourceHeaderAuthSetupTitleDescription")}
|
||||
formId="policy-header-auth-form"
|
||||
submitLabel={t("policyAuthSetHeaderAuth")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="policy-header-auth-form"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
onSave(data);
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthHeaderName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("policyAuthHeaderValue")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
placeholder={
|
||||
existingConfigured
|
||||
? "••••••••"
|
||||
: undefined
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extendedCompatibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="header-auth-compatibility-credenza"
|
||||
label={t("headerAuthCompatibility")}
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaShell>
|
||||
);
|
||||
}
|
||||
|
||||
type EmailCredenzaProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
emailEnabled: boolean;
|
||||
disabled?: boolean;
|
||||
emails: Tag[];
|
||||
onSave: (emails: Tag[]) => void;
|
||||
};
|
||||
|
||||
export function EmailCredenza({
|
||||
open,
|
||||
onOpenChange,
|
||||
emailEnabled,
|
||||
disabled,
|
||||
emails,
|
||||
onSave
|
||||
}: EmailCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [draftEmails, setDraftEmails] = useState<Tag[]>(emails);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraftEmails(emails);
|
||||
}
|
||||
}, [open, emails]);
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="max-w-lg">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("policyAuthEmailTitle")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("policyAuthEmailDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<form
|
||||
id="policy-email-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSave(draftEmails);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{!emailEnabled && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("otpEmailSmtpRequired")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("otpEmailSmtpRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{emailEnabled && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpEmailWhitelistListDescription")}
|
||||
</p>
|
||||
)}
|
||||
{emailEnabled && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("otpEmailWhitelistList")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
activeTagIndex={activeEmailTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder={t("otpEmailEnter")}
|
||||
tags={draftEmails}
|
||||
setTags={(newEmails) => {
|
||||
if (!disabled) {
|
||||
setDraftEmails(
|
||||
newEmails as Tag[]
|
||||
);
|
||||
}
|
||||
}}
|
||||
validateTag={(tag) =>
|
||||
z
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/
|
||||
)
|
||||
)
|
||||
.safeParse(tag).success
|
||||
}
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("otpEmailEnterDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{emailEnabled && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="policy-email-form"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("policyAuthSetEmailWhitelist")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
101
src/components/resource-policy/PolicyAuthMethodRow.tsx
Normal file
101
src/components/resource-policy/PolicyAuthMethodRow.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type PolicyAuthMethodRowProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
active: boolean;
|
||||
onConfigure: () => void;
|
||||
onToggle: (active: boolean) => void;
|
||||
disabled?: boolean;
|
||||
configureDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthMethodRow({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
summary,
|
||||
active,
|
||||
onConfigure,
|
||||
onToggle,
|
||||
disabled,
|
||||
configureDisabled = disabled
|
||||
}: PolicyAuthMethodRowProps) {
|
||||
const t = useTranslations();
|
||||
const canEdit = active && !configureDisabled;
|
||||
const canEnable = !active && !disabled;
|
||||
const isRowInteractive = canEdit || canEnable;
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (canEdit) {
|
||||
onConfigure();
|
||||
return;
|
||||
}
|
||||
if (canEnable) {
|
||||
onToggle(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
|
||||
disabled && "opacity-60",
|
||||
isRowInteractive && "cursor-pointer hover:bg-muted/50"
|
||||
)}
|
||||
onClick={isRowInteractive ? handleRowClick : undefined}
|
||||
onKeyDown={
|
||||
isRowInteractive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={isRowInteractive ? "button" : undefined}
|
||||
tabIndex={isRowInteractive ? 0 : undefined}
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{active ? summary : description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{active && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-0"
|
||||
disabled={configureDisabled}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
id={`${id}-toggle`}
|
||||
checked={active}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/resource-policy/PolicyAuthSsoSection.tsx
Normal file
140
src/components/resource-policy/PolicyAuthSsoSection.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsSectionForm } from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { FormDescription, FormItem, FormLabel } from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type PolicyAuthSsoSectionProps = {
|
||||
sso: boolean;
|
||||
onSsoChange: (active: boolean) => void;
|
||||
skipToIdpId: number | null | undefined;
|
||||
onSkipToIdpChange: (id: number | null) => void;
|
||||
allIdps: { id: number; text: string }[];
|
||||
rolesEditor: React.ReactNode;
|
||||
usersEditor: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
idpDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthSsoSection({
|
||||
sso,
|
||||
onSsoChange,
|
||||
skipToIdpId,
|
||||
onSkipToIdpChange,
|
||||
allIdps,
|
||||
rolesEditor,
|
||||
usersEditor,
|
||||
disabled,
|
||||
idpDisabled
|
||||
}: PolicyAuthSsoSectionProps) {
|
||||
const t = useTranslations();
|
||||
const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipToIdpId != null) {
|
||||
setShowIdpSelect(true);
|
||||
}
|
||||
}, [skipToIdpId]);
|
||||
|
||||
const idpSelectDisabled = idpDisabled ?? disabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SwitchInput
|
||||
id="policy-auth-sso"
|
||||
label={t("policyAuthSsoTitle")}
|
||||
description={t("policyAuthSsoDescription")}
|
||||
checked={sso}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onSsoChange}
|
||||
/>
|
||||
|
||||
{sso && (
|
||||
<SettingsSectionForm className="max-w-none space-y-4">
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
{rolesEditor}
|
||||
</FormItem>
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
{usersEditor}
|
||||
</FormItem>
|
||||
{allIdps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{skipToIdpId == null && !showIdpSelect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-0"
|
||||
disabled={idpSelectDisabled}
|
||||
onClick={() => setShowIdpSelect(true)}
|
||||
>
|
||||
{t("policyAuthAddDefaultIdentityProvider")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<label className="text-sm font-medium">
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
disabled={idpSelectDisabled}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
onSkipToIdpChange(null);
|
||||
setShowIdpSelect(false);
|
||||
return;
|
||||
}
|
||||
onSkipToIdpChange(parseInt(value));
|
||||
}}
|
||||
value={
|
||||
skipToIdpId
|
||||
? skipToIdpId.toString()
|
||||
: "none"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/resource-policy/PolicyAuthStackSection.tsx
Normal file
38
src/components/resource-policy/PolicyAuthStackSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import type { PolicyFormValues } from ".";
|
||||
import { PolicyAuthStackSectionCreate } from "./PolicyAuthStackSectionCreate";
|
||||
import { PolicyAuthStackSectionEdit } from "./PolicyAuthStackSectionEdit";
|
||||
|
||||
type PolicyAuthStackSectionEditProps = {
|
||||
mode: "edit";
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type PolicyAuthStackSectionCreateProps = {
|
||||
mode: "create";
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export type PolicyAuthStackSectionProps =
|
||||
| PolicyAuthStackSectionEditProps
|
||||
| PolicyAuthStackSectionCreateProps;
|
||||
|
||||
export function PolicyAuthStackSection(props: PolicyAuthStackSectionProps) {
|
||||
if (props.mode === "create") {
|
||||
const { mode: _, ...createProps } = props;
|
||||
return <PolicyAuthStackSectionCreate {...createProps} />;
|
||||
}
|
||||
const { mode: _, ...editProps } = props;
|
||||
return <PolicyAuthStackSectionEdit {...editProps} />;
|
||||
}
|
||||
310
src/components/resource-policy/PolicyAuthStackSectionCreate.tsx
Normal file
310
src/components/resource-policy/PolicyAuthStackSectionCreate.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { TagInput } from "@app/components/tags/tag-input";
|
||||
import { FormField } from "@app/components/ui/form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||
import type { PolicyFormValues } from ".";
|
||||
import {
|
||||
EmailCredenza,
|
||||
HeaderAuthCredenza,
|
||||
PasscodeCredenza,
|
||||
PincodeCredenza
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import {
|
||||
getEmailWhitelistSummary,
|
||||
getHeaderAuthSummary,
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
|
||||
export type PolicyAuthStackSectionCreateProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthStackSectionCreate({
|
||||
form: parentForm,
|
||||
allIdps,
|
||||
allRoles,
|
||||
allUsers,
|
||||
emailEnabled
|
||||
}: PolicyAuthStackSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const sso = useWatch({ control: parentForm.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const password = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "password"
|
||||
});
|
||||
const pincode = useWatch({ control: parentForm.control, name: "pincode" });
|
||||
const headerAuth = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "headerAuth"
|
||||
});
|
||||
const emailWhitelistEnabled = useWatch({
|
||||
control: parentForm.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
const emails =
|
||||
useWatch({ control: parentForm.control, name: "emails" }) ?? [];
|
||||
|
||||
const passcodeActive = Boolean(password);
|
||||
const pinActive = Boolean(pincode);
|
||||
const headerAuthActive = Boolean(headerAuth);
|
||||
|
||||
const closeCredenza = () => setEditingMethod(null);
|
||||
|
||||
const handleToggle = (
|
||||
method: PolicyAuthMethodId,
|
||||
active: boolean,
|
||||
onDisable: () => void,
|
||||
onEnable?: () => void
|
||||
) => {
|
||||
if (active) {
|
||||
onEnable?.();
|
||||
setEditingMethod(method);
|
||||
return;
|
||||
}
|
||||
onDisable();
|
||||
setEditingMethod((current) => (current === method ? null : current));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAuthStackTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("policyAuthStackDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
parentForm.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
parentForm.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
rolesEditor={
|
||||
<FormField<PolicyFormValues, "roles">
|
||||
control={parentForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newRoles) =>
|
||||
field.onChange(newRoles)
|
||||
}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
usersEditor={
|
||||
<FormField<PolicyFormValues, "users">
|
||||
control={parentForm.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t("accessUserSelect")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newUsers) =>
|
||||
field.onChange(newUsers)
|
||||
}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t("policyAuthPincodeDescription")}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() => setEditingMethod("pincode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () =>
|
||||
parentForm.setValue("pincode", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t("policyAuthPasscodeDescription")}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() => setEditingMethod("passcode")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () =>
|
||||
parentForm.setValue("password", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t("policyAuthEmailDescription")}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() => setEditingMethod("email")}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
parentForm.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={!emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t("policyAuthHeaderAuthDescription")}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() => setEditingMethod("headerAuth")}
|
||||
onToggle={(active) =>
|
||||
handleToggle("headerAuth", active, () =>
|
||||
parentForm.setValue("headerAuth", null)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
open={editingMethod === "pincode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPincode={pincode?.pincode ?? ""}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("pincode", { pincode: value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasscodeCredenza
|
||||
open={editingMethod === "passcode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPassword={password?.password ?? ""}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("password", { password: value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmailCredenza
|
||||
open={editingMethod === "email"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
emailEnabled={emailEnabled}
|
||||
emails={emails}
|
||||
onSave={(value) =>
|
||||
parentForm.setValue(
|
||||
"emails",
|
||||
value as PolicyFormValues["emails"]
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HeaderAuthCredenza
|
||||
open={editingMethod === "headerAuth"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultValues={
|
||||
headerAuth
|
||||
? {
|
||||
user: headerAuth.user,
|
||||
password: headerAuth.password,
|
||||
extendedCompatibility:
|
||||
headerAuth.extendedCompatibility
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={(value) => {
|
||||
parentForm.setValue("headerAuth", value);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
758
src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
Normal file
758
src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
Normal file
@@ -0,0 +1,758 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form, FormField } from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { createPolicySchema } from ".";
|
||||
import {
|
||||
EmailCredenza,
|
||||
HeaderAuthCredenza,
|
||||
PasscodeCredenza,
|
||||
PincodeCredenza
|
||||
} from "./PolicyAuthMethodCredenzas";
|
||||
import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow";
|
||||
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
|
||||
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
|
||||
import {
|
||||
getEmailWhitelistSummary,
|
||||
getHeaderAuthSummary,
|
||||
getPasscodeSummary,
|
||||
getPincodeSummary
|
||||
} from "./policy-auth-summaries";
|
||||
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
|
||||
import z from "zod";
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
// Edit mode keeps placeholder values for configured methods; only validate on save when changed.
|
||||
const authStackEditSchema = createPolicySchema
|
||||
.pick({
|
||||
sso: true,
|
||||
skipToIdpId: true,
|
||||
roles: true,
|
||||
users: true,
|
||||
emailWhitelistEnabled: true,
|
||||
emails: true
|
||||
})
|
||||
.extend({
|
||||
password: z
|
||||
.object({
|
||||
password: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
pincode: z
|
||||
.object({
|
||||
pincode: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
extendedCompatibility: z.boolean().default(true)
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type PolicyAuthStackSectionEditProps = {
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export function PolicyAuthStackSectionEdit({
|
||||
orgId,
|
||||
allIdps,
|
||||
emailEnabled,
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyAuthStackSectionEditProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
const authReadonly = readonly || isResourceOverlay;
|
||||
|
||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: false
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserItems = useMemo(
|
||||
() =>
|
||||
policy.users.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
})),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
const policyRoleLockedIds = useMemo(
|
||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserLockedIds = useMemo(
|
||||
() => new Set(policy.users.map((u) => u.userId)),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
const { data: resourceRolesData } = useQuery({
|
||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
const { data: resourceUsersData } = useQuery({
|
||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const [combinedRoles, setCombinedRoles] =
|
||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||
useState(false);
|
||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||
if (!resourceRolesData) return;
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
}));
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles(
|
||||
[...policyRoleItems, ...resourceSpecific].filter(
|
||||
(role) => !role.isAdmin
|
||||
)
|
||||
);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRolesData,
|
||||
resourceRolesInitialized,
|
||||
policyRoleItems,
|
||||
policyRoleLockedIds
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||
if (!resourceUsersData) return;
|
||||
const resourceSpecific = resourceUsersData
|
||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||
.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
initialResourceUserIdsRef.current = new Set(
|
||||
resourceSpecific.map((u) => u.id)
|
||||
);
|
||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||
setResourceUsersInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceUsersData,
|
||||
resourceUsersInitialized,
|
||||
policyUserItems,
|
||||
policyUserLockedIds
|
||||
]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(authStackEditSchema),
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems,
|
||||
password: null,
|
||||
pincode: null,
|
||||
headerAuth: policy.headerAuth
|
||||
? {
|
||||
user: "",
|
||||
password: "",
|
||||
extendedCompatibility:
|
||||
policy.headerAuth.extendedCompability ?? true
|
||||
}
|
||||
: null,
|
||||
emailWhitelistEnabled: policy.emailWhitelistEnabled,
|
||||
emails: policy.emailWhiteList.map((email) => ({
|
||||
id: email.whiteListId.toString(),
|
||||
text: email.email
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
const [passcodeActive, setPasscodeActive] = useState(
|
||||
Boolean(policy.passwordId)
|
||||
);
|
||||
const [pinActive, setPinActive] = useState(Boolean(policy.pincodeId));
|
||||
const [headerAuthActive, setHeaderAuthActive] = useState(
|
||||
Boolean(policy.headerAuth)
|
||||
);
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
|
||||
const sso = useWatch({ control: form.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
control: form.control,
|
||||
name: "skipToIdpId"
|
||||
});
|
||||
const roles = useWatch({ control: form.control, name: "roles" }) ?? [];
|
||||
const users = useWatch({ control: form.control, name: "users" }) ?? [];
|
||||
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 emailWhitelistEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: "emailWhitelistEnabled"
|
||||
});
|
||||
const emails = useWatch({ control: form.control, name: "emails" }) ?? [];
|
||||
|
||||
const overlayRoles = combinedRoles.filter((r) => !r.isAdmin);
|
||||
const overlayUsers = combinedUsers;
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly && !isResourceOverlay) return;
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = form.getValues();
|
||||
const requests: Array<Promise<AxiosResponse<{}> | void>> = [];
|
||||
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/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(handleError)
|
||||
);
|
||||
|
||||
if (passcodeActive && payload.password?.password) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{ password: payload.password.password }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!passcodeActive && policy.passwordId) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/password`,
|
||||
{ password: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
if (pinActive && payload.pincode?.pincode?.length === 6) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{ pincode: payload.pincode.pincode }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!pinActive && policy.pincodeId) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/pincode`,
|
||||
{ pincode: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
headerAuthActive &&
|
||||
payload.headerAuth?.user &&
|
||||
payload.headerAuth?.password
|
||||
) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{ headerAuth: payload.headerAuth }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
} else if (!headerAuthActive && policy.headerAuth) {
|
||||
requests.push(
|
||||
api
|
||||
.put(
|
||||
`/resource-policy/${policy.resourcePolicyId}/header-auth`,
|
||||
{ headerAuth: null }
|
||||
)
|
||||
.catch(handleError)
|
||||
);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
api
|
||||
.put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, {
|
||||
emailWhitelistEnabled: payload.emailWhitelistEnabled,
|
||||
emails: payload.emails?.map((e) => e.text) ?? []
|
||||
})
|
||||
.catch(handleError)
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(requests);
|
||||
if (results.every((res) => res && res.status === 200)) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: t("policyErrorUpdateMessageDescription")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(e: unknown) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(e, t("policyErrorUpdateDescription"))
|
||||
});
|
||||
}
|
||||
|
||||
async function saveResourceOverlay() {
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
const currentResourceRoleIds = combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => Number(r.id));
|
||||
const currentResourceUserIds = combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id);
|
||||
|
||||
await Promise.all([
|
||||
api.post(`/resource/${resourceId}/roles`, {
|
||||
roleIds: currentResourceRoleIds
|
||||
}),
|
||||
api.post(`/resource/${resourceId}/users`, {
|
||||
userIds: currentResourceUserIds
|
||||
})
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsSavingOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isResourceOverlay &&
|
||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||
|
||||
const closeCredenza = () => setEditingMethod(null);
|
||||
|
||||
const openMethodEditor = (method: PolicyAuthMethodId) => {
|
||||
setEditingMethod(method);
|
||||
};
|
||||
|
||||
const handleToggle = (
|
||||
method: PolicyAuthMethodId,
|
||||
active: boolean,
|
||||
onDisable: () => void,
|
||||
onEnable?: () => void
|
||||
) => {
|
||||
if (active) {
|
||||
onEnable?.();
|
||||
openMethodEditor(method);
|
||||
return;
|
||||
}
|
||||
onDisable();
|
||||
setEditingMethod((current) => (current === method ? null : current));
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("policyAuthStackTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("policyAuthStackDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-4">
|
||||
{isResourceOverlay && (
|
||||
<SharedPolicyResourceNotice section="authentication" />
|
||||
)}
|
||||
<div className="w-full md:w-1/2">
|
||||
<PolicyAuthSsoSection
|
||||
sso={Boolean(sso)}
|
||||
onSsoChange={(active) =>
|
||||
form.setValue("sso", active)
|
||||
}
|
||||
skipToIdpId={skipToIdpId}
|
||||
onSkipToIdpChange={(id) =>
|
||||
form.setValue("skipToIdpId", id)
|
||||
}
|
||||
allIdps={allIdps}
|
||||
disabled={authReadonly}
|
||||
idpDisabled={authReadonly}
|
||||
rolesEditor={
|
||||
isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={overlayRoles}
|
||||
onSelectRoles={(selected) =>
|
||||
setCombinedRoles(
|
||||
selected.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={policyRoleLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
usersEditor={
|
||||
isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={overlayUsers}
|
||||
onSelectUsers={setCombinedUsers}
|
||||
disabled={isLoading}
|
||||
lockedIds={policyUserLockedIds}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
selected
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("policyAuthOtherMethodsTitle")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("policyAuthOtherMethodsDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PolicyAuthMethodRow
|
||||
id="pincode"
|
||||
title={t("policyAuthPincodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPincodeDescription"
|
||||
)}
|
||||
summary={getPincodeSummary({ t })}
|
||||
active={pinActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("pincode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("pincode", active, () => {
|
||||
setPinActive(false);
|
||||
form.setValue("pincode", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="passcode"
|
||||
title={t("policyAuthPasscodeTitle")}
|
||||
description={t(
|
||||
"policyAuthPasscodeDescription"
|
||||
)}
|
||||
summary={getPasscodeSummary({ t })}
|
||||
active={passcodeActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("passcode")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle("passcode", active, () => {
|
||||
setPasscodeActive(false);
|
||||
form.setValue("password", null);
|
||||
})
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="email"
|
||||
title={t("policyAuthEmailTitle")}
|
||||
description={t(
|
||||
"policyAuthEmailDescription"
|
||||
)}
|
||||
summary={getEmailWhitelistSummary({
|
||||
t,
|
||||
count: emails.length
|
||||
})}
|
||||
active={Boolean(emailWhitelistEnabled)}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("email")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"email",
|
||||
active,
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
false
|
||||
),
|
||||
() =>
|
||||
form.setValue(
|
||||
"emailWhitelistEnabled",
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={authReadonly || !emailEnabled}
|
||||
/>
|
||||
|
||||
<PolicyAuthMethodRow
|
||||
id="header-auth"
|
||||
title={t("policyAuthHeaderAuthTitle")}
|
||||
description={t(
|
||||
"policyAuthHeaderAuthDescription"
|
||||
)}
|
||||
summary={getHeaderAuthSummary({
|
||||
t,
|
||||
headerName: headerAuth?.user ?? ""
|
||||
})}
|
||||
active={headerAuthActive}
|
||||
onConfigure={() =>
|
||||
openMethodEditor("headerAuth")
|
||||
}
|
||||
onToggle={(active) =>
|
||||
handleToggle(
|
||||
"headerAuth",
|
||||
active,
|
||||
() => {
|
||||
setHeaderAuthActive(false);
|
||||
form.setValue(
|
||||
"headerAuth",
|
||||
null
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
disabled={authReadonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PincodeCredenza
|
||||
open={editingMethod === "pincode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPincode={pincode?.pincode ?? ""}
|
||||
onSave={(value) => {
|
||||
form.setValue("pincode", { pincode: value });
|
||||
setPinActive(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasscodeCredenza
|
||||
open={editingMethod === "passcode"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultPassword={password?.password ?? ""}
|
||||
existingConfigured={Boolean(policy.passwordId)}
|
||||
onSave={(value) => {
|
||||
form.setValue("password", { password: value });
|
||||
setPasscodeActive(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmailCredenza
|
||||
open={editingMethod === "email"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
emailEnabled={emailEnabled}
|
||||
disabled={authReadonly}
|
||||
emails={emails}
|
||||
onSave={(value) => form.setValue("emails", value)}
|
||||
/>
|
||||
|
||||
<HeaderAuthCredenza
|
||||
open={editingMethod === "headerAuth"}
|
||||
onOpenChange={(open) => !open && closeCredenza()}
|
||||
defaultValues={
|
||||
headerAuth
|
||||
? {
|
||||
user: headerAuth.user,
|
||||
password: headerAuth.password,
|
||||
extendedCompatibility:
|
||||
headerAuth.extendedCompatibility ??
|
||||
true
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
existingConfigured={Boolean(policy.headerAuth)}
|
||||
onSave={(value) => {
|
||||
form.setValue("headerAuth", value);
|
||||
setHeaderAuthActive(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting || isSavingOverlay}
|
||||
disabled={
|
||||
(readonly && !isResourceOverlay) ||
|
||||
isSubmitting ||
|
||||
isSavingOverlay ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{t("authMethodsSave")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal file
47
src/components/resource-policy/ResourcePolicyEditForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { EditPolicyForm, type EditPolicyFormSection } from "./EditPolicyForm";
|
||||
|
||||
type ResourcePolicyEditFormProps = {
|
||||
section: Extract<EditPolicyFormSection, "authentication" | "rules">;
|
||||
};
|
||||
|
||||
export function ResourcePolicyEditForm({
|
||||
section
|
||||
}: ResourcePolicyEditFormProps) {
|
||||
const { resource } = useResourceContext();
|
||||
|
||||
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
|
||||
resourceQueries.policies({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingPolicies || !policies) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!policies.sharedPolicy) {
|
||||
return (
|
||||
<ResourcePolicyProvider policy={policies.defaultPolicy}>
|
||||
<EditPolicyForm hidePolicyNameForm section={section} />
|
||||
</ResourcePolicyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePolicyProvider
|
||||
policy={policies.sharedPolicy}
|
||||
key={policies.sharedPolicy.resourcePolicyId}
|
||||
>
|
||||
<EditPolicyForm
|
||||
resourceId={resource.resourceId}
|
||||
section={section}
|
||||
/>
|
||||
</ResourcePolicyProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SharedPolicyResourceNoticeProps = {
|
||||
section: "authentication" | "rules";
|
||||
};
|
||||
|
||||
export function SharedPolicyResourceNotice({
|
||||
section
|
||||
}: SharedPolicyResourceNoticeProps) {
|
||||
const t = useTranslations();
|
||||
const { org } = useOrgContext();
|
||||
const { policy } = useResourcePolicyContext();
|
||||
|
||||
const messageKey =
|
||||
section === "authentication"
|
||||
? "resourceSharedPolicyAuthenticationNotice"
|
||||
: "resourceSharedPolicyRulesNotice";
|
||||
|
||||
return (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t.rich(messageKey, {
|
||||
policyName: policy.name,
|
||||
policyLink: (chunks) => (
|
||||
<Link
|
||||
href={`/${org.org.orgId}/settings/policies/resources/public/${policy.niceId}/${section}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// ─── Schemas & types ──────────────────────────────────────────────────────────
|
||||
|
||||
import z from "zod";
|
||||
import { POLICY_RULE_MATCH_TYPES } from "./policy-access-rule-validation";
|
||||
|
||||
export const createPolicySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -35,7 +36,7 @@ export const createPolicySchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: z.string(),
|
||||
match: z.enum(POLICY_RULE_MATCH_TYPES),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
@@ -46,13 +47,6 @@ export const createPolicySchema = z.object({
|
||||
|
||||
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";
|
||||
@@ -63,3 +57,29 @@ export type LocalRule = {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
};
|
||||
|
||||
export { PolicyAccessRulesTable } from "./PolicyAccessRulesTable";
|
||||
export type { PolicyAccessRulesTableProps } from "./PolicyAccessRulesTable";
|
||||
export {
|
||||
createEmptyRule,
|
||||
reorderPolicyRules,
|
||||
sortPolicyRulesByPriority,
|
||||
type EmptyRuleDraft,
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
export {
|
||||
createPolicyRuleMatchSchema,
|
||||
createPolicyRulePrioritySchema,
|
||||
createPolicyRuleSchema,
|
||||
createPolicyRuleValueSchema,
|
||||
createPolicyRulesArraySchema,
|
||||
createPolicyRulesSectionSchema,
|
||||
createPolicySchemaWithI18n,
|
||||
getPolicyRuleValidationMessage,
|
||||
POLICY_RULE_MATCH_TYPES,
|
||||
validatePolicyRulePriority,
|
||||
validatePolicyRuleValue,
|
||||
validatePolicyRulesForSave,
|
||||
type PolicyRuleMatchType,
|
||||
type RuleValidationToast
|
||||
} from "./policy-access-rule-validation";
|
||||
|
||||
193
src/components/resource-policy/policy-access-rule-utils.ts
Normal file
193
src/components/resource-policy/policy-access-rule-utils.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
export type PolicyAccessRule = {
|
||||
ruleId: number;
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
match: string;
|
||||
value: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
fromPolicy?: boolean;
|
||||
};
|
||||
|
||||
export type EmptyRuleDraft = PolicyAccessRule & {
|
||||
new: true;
|
||||
};
|
||||
|
||||
export function createEmptyRule(
|
||||
existingRules: Array<{ priority: number }>
|
||||
): EmptyRuleDraft {
|
||||
const priority =
|
||||
existingRules.reduce(
|
||||
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||
0
|
||||
) + 1;
|
||||
|
||||
return {
|
||||
ruleId: Date.now(),
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
value: "",
|
||||
priority,
|
||||
enabled: true,
|
||||
new: true
|
||||
};
|
||||
}
|
||||
|
||||
export function prependEmptyRule(
|
||||
rules: PolicyAccessRule[]
|
||||
): PolicyAccessRule[] {
|
||||
const newRule: EmptyRuleDraft = {
|
||||
ruleId: Date.now(),
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
value: "",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
new: true
|
||||
};
|
||||
|
||||
const bumpedRules = rules.map((rule) => {
|
||||
if (rule.fromPolicy) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const bumped = { ...rule, priority: rule.priority + 1 };
|
||||
if (rule.new) {
|
||||
return bumped;
|
||||
}
|
||||
return { ...bumped, updated: true };
|
||||
});
|
||||
|
||||
return [newRule, ...bumpedRules];
|
||||
}
|
||||
|
||||
export function sortPolicyRulesByPriority<T extends { priority: number }>(
|
||||
rules: T[]
|
||||
): T[] {
|
||||
return [...rules].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
export function sortPolicyRulesForResourceOverlay<
|
||||
T extends { priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): T[] {
|
||||
const resourceRules = rules
|
||||
.filter((rule) => !rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
const policyRules = rules
|
||||
.filter((rule) => rule.fromPolicy)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
return [...resourceRules, ...policyRules];
|
||||
}
|
||||
|
||||
export function buildDisplayPrioritiesForResourceOverlay<
|
||||
T extends { ruleId: number; priority: number; fromPolicy?: boolean }
|
||||
>(rules: T[]): Map<number, number> {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const displayPriorities = new Map<number, number>();
|
||||
|
||||
sorted.forEach((rule, index) => {
|
||||
displayPriorities.set(rule.ruleId, index + 1);
|
||||
});
|
||||
|
||||
return displayPriorities;
|
||||
}
|
||||
|
||||
export function setResourceRuleDisplayPriority(
|
||||
rules: PolicyAccessRule[],
|
||||
ruleId: number,
|
||||
displayPriority: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): PolicyAccessRule[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex((rule) => rule.ruleId === ruleId);
|
||||
if (fromIndex === -1) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const targetIndex = Math.max(
|
||||
0,
|
||||
Math.min(displayPriority - 1, resourceRules.length - 1)
|
||||
);
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
targetIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
|
||||
export function reorderPolicyRules<
|
||||
T extends { priority: number; new?: boolean; updated?: boolean }
|
||||
>(
|
||||
rules: T[],
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): T[] {
|
||||
if (
|
||||
fromIndex === toIndex ||
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0 ||
|
||||
fromIndex >= rules.length ||
|
||||
toIndex >= rules.length
|
||||
) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const reordered = [...rules];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
|
||||
return reordered.map((rule, index) => {
|
||||
const next = { ...rule, priority: index + 1 };
|
||||
if (options?.markUpdated && !rule.new) {
|
||||
return { ...next, updated: true };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderResourceOverlayRules<
|
||||
T extends {
|
||||
ruleId: number;
|
||||
priority: number;
|
||||
fromPolicy?: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
}
|
||||
>(
|
||||
rules: T[],
|
||||
fromRuleId: number,
|
||||
toRuleId: number,
|
||||
options?: { markUpdated?: boolean }
|
||||
): T[] {
|
||||
const sorted = sortPolicyRulesForResourceOverlay(rules);
|
||||
const resourceRules = sorted.filter((rule) => !rule.fromPolicy);
|
||||
const policyRules = sorted.filter((rule) => rule.fromPolicy);
|
||||
|
||||
const fromIndex = resourceRules.findIndex(
|
||||
(rule) => rule.ruleId === fromRuleId
|
||||
);
|
||||
const toIndex = resourceRules.findIndex((rule) => rule.ruleId === toRuleId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const reorderedResource = reorderPolicyRules(
|
||||
resourceRules,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
options
|
||||
);
|
||||
|
||||
return [...reorderedResource, ...policyRules];
|
||||
}
|
||||
254
src/components/resource-policy/policy-access-rule-validation.ts
Normal file
254
src/components/resource-policy/policy-access-rule-validation.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import z from "zod";
|
||||
|
||||
type TranslateFn = (
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
export const POLICY_RULE_MATCH_TYPES = [
|
||||
"CIDR",
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
|
||||
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
||||
|
||||
export function createPolicyRuleMatchSchema(t: TranslateFn) {
|
||||
return z.enum(POLICY_RULE_MATCH_TYPES, {
|
||||
error: t("rulesErrorInvalidMatchTypeDescription")
|
||||
});
|
||||
}
|
||||
|
||||
export type RuleValidationToast = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function getPolicyRuleValidationMessage(
|
||||
t: TranslateFn,
|
||||
issue: z.core.$ZodIssue
|
||||
): string {
|
||||
const ruleIndex = issue.path.find((segment) => typeof segment === "number");
|
||||
if (typeof ruleIndex === "number") {
|
||||
return t("rulesErrorValidationRuleDescription", {
|
||||
ruleNumber: ruleIndex + 1,
|
||||
message: issue.message
|
||||
});
|
||||
}
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
export function createPolicyRulePrioritySchema(t: TranslateFn) {
|
||||
return z.coerce
|
||||
.number({ error: t("rulesErrorInvalidPriorityDescription") })
|
||||
.int({ message: t("rulesErrorInvalidPriorityDescription") })
|
||||
.min(1, { message: t("rulesErrorInvalidPriorityDescription") });
|
||||
}
|
||||
|
||||
export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
||||
const required = z
|
||||
.string()
|
||||
.min(1, { message: t("rulesErrorValueRequired") });
|
||||
|
||||
switch (match) {
|
||||
case "CIDR":
|
||||
return required.refine(isValidCIDR, {
|
||||
message: t("rulesErrorInvalidIpAddressRangeDescription")
|
||||
});
|
||||
case "IP":
|
||||
return required.refine(isValidIP, {
|
||||
message: t("rulesErrorInvalidIpAddressDescription")
|
||||
});
|
||||
case "PATH":
|
||||
return required.refine(isValidUrlGlobPattern, {
|
||||
message: t("rulesErrorInvalidUrlDescription")
|
||||
});
|
||||
case "REGION":
|
||||
return required.refine(isValidRegionId, {
|
||||
message: t("rulesErrorInvalidRegionDescription")
|
||||
});
|
||||
case "COUNTRY":
|
||||
return required.refine(
|
||||
(value) => COUNTRIES.some((country) => country.code === value),
|
||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||
);
|
||||
case "ASN":
|
||||
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
|
||||
message: t("rulesErrorInvalidAsnDescription")
|
||||
});
|
||||
default:
|
||||
return required;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPolicyRuleSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||
match: createPolicyRuleMatchSchema(t),
|
||||
value: z.string(),
|
||||
priority: z.number().int(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
.superRefine((rule, ctx) => {
|
||||
const priorityResult = createPolicyRulePrioritySchema(t).safeParse(
|
||||
rule.priority
|
||||
);
|
||||
if (!priorityResult.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
priorityResult.error.issues[0]?.message ??
|
||||
t("rulesErrorInvalidPriorityDescription"),
|
||||
path: ["priority"]
|
||||
});
|
||||
}
|
||||
|
||||
const valueResult = createPolicyRuleValueSchema(
|
||||
t,
|
||||
rule.match
|
||||
).safeParse(rule.value);
|
||||
if (!valueResult.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
valueResult.error.issues[0]?.message ??
|
||||
t("rulesErrorValueRequired"),
|
||||
path: ["value"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicyRulesArraySchema(t: TranslateFn) {
|
||||
return z.array(createPolicyRuleSchema(t)).superRefine((rules, ctx) => {
|
||||
const seenPriorities = new Set<number>();
|
||||
rules.forEach((rule, index) => {
|
||||
if (seenPriorities.has(rule.priority)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("rulesErrorDuplicatePriorityDescription"),
|
||||
path: [index, "priority"]
|
||||
});
|
||||
}
|
||||
seenPriorities.add(rule.priority);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicyRulesSectionSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
applyRules: z.boolean(),
|
||||
rules: createPolicyRulesArraySchema(t)
|
||||
});
|
||||
}
|
||||
|
||||
export function createPolicySchemaWithI18n(
|
||||
t: TranslateFn,
|
||||
baseSchema: z.ZodObject<z.ZodRawShape>
|
||||
) {
|
||||
return baseSchema.extend({
|
||||
rules: createPolicyRulesArraySchema(t)
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePolicyRulePriority(
|
||||
t: TranslateFn,
|
||||
value: unknown
|
||||
):
|
||||
| { success: true; data: number }
|
||||
| { success: false; toast: RuleValidationToast } {
|
||||
const result = createPolicyRulePrioritySchema(t).safeParse(value);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t("rulesErrorInvalidPriority"),
|
||||
description:
|
||||
result.error.issues[0]?.message ??
|
||||
t("rulesErrorInvalidPriorityDescription")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePolicyRuleValue(
|
||||
t: TranslateFn,
|
||||
match: string,
|
||||
value: string
|
||||
):
|
||||
| { success: true; data: string }
|
||||
| { success: false; toast: RuleValidationToast } {
|
||||
const result = createPolicyRuleValueSchema(t, match).safeParse(value);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
const issue = result.error.issues[0];
|
||||
const titleKey =
|
||||
match === "CIDR"
|
||||
? "rulesErrorInvalidIpAddressRange"
|
||||
: match === "IP"
|
||||
? "rulesErrorInvalidIpAddress"
|
||||
: match === "PATH"
|
||||
? "rulesErrorInvalidUrl"
|
||||
: match === "REGION"
|
||||
? "rulesErrorInvalidRegion"
|
||||
: match === "COUNTRY"
|
||||
? "rulesErrorInvalidCountry"
|
||||
: match === "ASN"
|
||||
? "rulesErrorInvalidAsn"
|
||||
: "rulesErrorValidation";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t(titleKey),
|
||||
description: issue?.message ?? t("rulesErrorValueRequired")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePolicyRulesForSave(
|
||||
t: TranslateFn,
|
||||
rules: Array<{
|
||||
action: "ACCEPT" | "DROP" | "PASS";
|
||||
match: string;
|
||||
value: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
applyRules: boolean
|
||||
): { success: true } | { success: false; toast: RuleValidationToast } {
|
||||
if (!applyRules) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const result = createPolicyRulesArraySchema(t).safeParse(rules);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const issue = result.error.issues[0];
|
||||
return {
|
||||
success: false,
|
||||
toast: {
|
||||
title: t("rulesErrorValidation"),
|
||||
description: issue
|
||||
? getPolicyRuleValidationMessage(t, issue)
|
||||
: t("rulesErrorUpdateDescription")
|
||||
}
|
||||
};
|
||||
}
|
||||
21
src/components/resource-policy/policy-auth-method-id.ts
Normal file
21
src/components/resource-policy/policy-auth-method-id.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
|
||||
export type PolicyAuthMethodId =
|
||||
| "pincode"
|
||||
| "passcode"
|
||||
| "email"
|
||||
| "headerAuth";
|
||||
|
||||
export const setPasswordSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
export const setPincodeSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
export const setHeaderAuthSchema = z.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
});
|
||||
45
src/components/resource-policy/policy-auth-summaries.ts
Normal file
45
src/components/resource-policy/policy-auth-summaries.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
type SummaryParams = {
|
||||
t: (key: string, values?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
type SsoSummaryParams = SummaryParams & {
|
||||
idpName?: string;
|
||||
userCount: number;
|
||||
roleCount: number;
|
||||
};
|
||||
|
||||
export function getSsoSummary({
|
||||
t,
|
||||
idpName,
|
||||
userCount,
|
||||
roleCount
|
||||
}: SsoSummaryParams) {
|
||||
const idp = idpName ?? t("policyAuthSsoDefaultIdp");
|
||||
return t("policyAuthSsoSummary", {
|
||||
idp,
|
||||
users: userCount,
|
||||
roles: roleCount
|
||||
});
|
||||
}
|
||||
|
||||
export function getPasscodeSummary({ t }: SummaryParams) {
|
||||
return t("policyAuthPasscodeSummary");
|
||||
}
|
||||
|
||||
export function getPincodeSummary({ t }: SummaryParams) {
|
||||
return t("policyAuthPincodeSummary");
|
||||
}
|
||||
|
||||
export function getEmailWhitelistSummary({
|
||||
t,
|
||||
count
|
||||
}: SummaryParams & { count: number }) {
|
||||
return t("policyAuthEmailSummary", { count });
|
||||
}
|
||||
|
||||
export function getHeaderAuthSummary({
|
||||
t,
|
||||
headerName
|
||||
}: SummaryParams & { headerName: string }) {
|
||||
return headerName || t("policyAuthHeaderAuthSummary");
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { DockerState } from "@app/lib/docker";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { type ListTargetsResponse } from "@server/routers/target";
|
||||
import type { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { ContainersSelector } from "./ContainersSelector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -28,23 +25,21 @@ export type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export type ResourceTargetAddressItemProps = {
|
||||
export type ResourceTargetSiteItemProps = {
|
||||
getDockerStateForSite: (siteId: number) => DockerState;
|
||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||
orgId: string;
|
||||
proxyTarget: LocalTarget;
|
||||
isHttp: boolean;
|
||||
refreshContainersForSite: (siteId: number) => void;
|
||||
};
|
||||
|
||||
export function ResourceTargetAddressItem({
|
||||
export function ResourceTargetSiteItem({
|
||||
orgId,
|
||||
getDockerStateForSite,
|
||||
updateTarget,
|
||||
proxyTarget,
|
||||
isHttp,
|
||||
refreshContainersForSite
|
||||
}: ResourceTargetAddressItemProps) {
|
||||
}: ResourceTargetSiteItemProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const [selectedSite, setSelectedSite] = useState<Pick<
|
||||
@@ -76,62 +71,78 @@ export function ResourceTargetAddressItem({
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center h-9 border border-input rounded-md"
|
||||
key={proxyTarget.targetId}
|
||||
>
|
||||
{selectedSite && selectedSite.type === "newt" && (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={
|
||||
getDockerStateForSite(selectedSite.siteId).containers
|
||||
}
|
||||
isAvailable={
|
||||
getDockerStateForSite(selectedSite.siteId).isAvailable
|
||||
}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(selectedSite.siteId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"h-9 min-w-0 flex-1 justify-between px-3 rounded-none hover:bg-transparent",
|
||||
!proxyTarget.siteId && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{proxyTarget.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
updateTarget(proxyTarget.targetId, {
|
||||
siteId: site.siteId,
|
||||
siteType: site.type,
|
||||
siteName: site.name
|
||||
});
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ResourceTargetAddressItemProps = {
|
||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||
proxyTarget: LocalTarget;
|
||||
isHttp: boolean;
|
||||
};
|
||||
|
||||
export function ResourceTargetAddressItem({
|
||||
updateTarget,
|
||||
proxyTarget,
|
||||
isHttp
|
||||
}: ResourceTargetAddressItemProps) {
|
||||
return (
|
||||
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
||||
{selectedSite && selectedSite.type === "newt" && (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.containers
|
||||
}
|
||||
isAvailable={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.isAvailable
|
||||
}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(selectedSite.siteId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||
"",
|
||||
!proxyTarget.siteId && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-37.5">
|
||||
{proxyTarget.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
updateTarget(proxyTarget.targetId, {
|
||||
siteId: site.siteId,
|
||||
siteType: site.type,
|
||||
siteName: site.name
|
||||
});
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isHttp && (
|
||||
<Select
|
||||
defaultValue={proxyTarget.method ?? "http"}
|
||||
@@ -142,7 +153,7 @@ export function ResourceTargetAddressItem({
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
|
||||
<SelectTrigger className="h-9 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none mr-0 pr-0">
|
||||
{proxyTarget.method || "http"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -154,7 +165,7 @@ export function ResourceTargetAddressItem({
|
||||
)}
|
||||
|
||||
{isHttp && (
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
<div className="flex items-center justify-center h-9 mr-0 pl-1">
|
||||
{"://"}
|
||||
</div>
|
||||
)}
|
||||
@@ -162,7 +173,7 @@ export function ResourceTargetAddressItem({
|
||||
<Input
|
||||
defaultValue={proxyTarget.ip}
|
||||
placeholder="Host"
|
||||
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
|
||||
className="flex-1 min-w-30 border-none placeholder-gray-400 rounded-xs"
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
@@ -195,7 +206,7 @@ export function ResourceTargetAddressItem({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
<div className="flex items-center justify-center h-9 mr-0">
|
||||
{":"}
|
||||
</div>
|
||||
<Input
|
||||
|
||||
218
src/components/shared-policy-selector.tsx
Normal file
218
src/components/shared-policy-selector.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
export type SelectedSharedPolicy = Pick<
|
||||
ListResourcePoliciesResponse["policies"][number],
|
||||
"resourcePolicyId" | "name"
|
||||
>;
|
||||
|
||||
export type SharedPolicySelectorProps = {
|
||||
orgId: string;
|
||||
selectedPolicy: SelectedSharedPolicy | null;
|
||||
onSelectPolicy: (policy: SelectedSharedPolicy | null) => void;
|
||||
};
|
||||
|
||||
export function SharedPolicySelector({
|
||||
orgId,
|
||||
selectedPolicy,
|
||||
onSelectPolicy
|
||||
}: SharedPolicySelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [policySearchQuery, setPolicySearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(policySearchQuery, 150);
|
||||
|
||||
const { data: policies = [] } = useQuery(
|
||||
orgQueries.policies({
|
||||
orgId,
|
||||
query: debouncedQuery
|
||||
})
|
||||
);
|
||||
|
||||
const policiesShown = useMemo((): SelectedSharedPolicy[] => {
|
||||
const allPolicies: SelectedSharedPolicy[] = policies.map((policy) => ({
|
||||
resourcePolicyId: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
}));
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedPolicy &&
|
||||
!allPolicies.find(
|
||||
(policy) =>
|
||||
policy.resourcePolicyId === selectedPolicy.resourcePolicyId
|
||||
)
|
||||
) {
|
||||
allPolicies.unshift(selectedPolicy);
|
||||
}
|
||||
return allPolicies;
|
||||
}, [debouncedQuery, policies, selectedPolicy]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("resourcePolicySearch")}
|
||||
value={policySearchQuery}
|
||||
onValueChange={setPolicySearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcePolicyNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={`none:${t("none")}`}
|
||||
onSelect={() => onSelectPolicy(null)}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedPolicy === null
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">{t("none")}</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{t("sharedPolicyNoneDescription")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{policiesShown.map((policy) => (
|
||||
<CommandItem
|
||||
key={policy.resourcePolicyId}
|
||||
value={`${policy.resourcePolicyId}:${policy.name}`}
|
||||
onSelect={() =>
|
||||
onSelectPolicy({
|
||||
resourcePolicyId: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
})
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
policy.resourcePolicyId ===
|
||||
selectedPolicy?.resourcePolicyId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{policy.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
export type SharedPolicySelectProps = {
|
||||
orgId: string;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function SharedPolicySelect({
|
||||
orgId,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
disabled
|
||||
}: SharedPolicySelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedLabel, setSelectedLabel] = useState<{
|
||||
resourcePolicyId: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const resolvedLabel =
|
||||
selectedLabel?.resourcePolicyId === value ? selectedLabel.name : null;
|
||||
|
||||
const { data: fetchedPolicy } = useQuery({
|
||||
...orgQueries.resourcePolicy({
|
||||
resourcePolicyId: value!
|
||||
}),
|
||||
enabled: value !== null && resolvedLabel === null
|
||||
});
|
||||
|
||||
const selectedPolicy = useMemo((): SelectedSharedPolicy | null => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourcePolicyId: value,
|
||||
name: resolvedLabel ?? fetchedPolicy?.name ?? ""
|
||||
};
|
||||
}, [value, resolvedLabel, fetchedPolicy?.name]);
|
||||
|
||||
const triggerLabel =
|
||||
value === null
|
||||
? t("none")
|
||||
: (resolvedLabel ??
|
||||
fetchedPolicy?.name ??
|
||||
t("resourcePolicySelect"));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal md:w-1/2",
|
||||
value !== null &&
|
||||
!resolvedLabel &&
|
||||
!fetchedPolicy?.name &&
|
||||
"text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SharedPolicySelector
|
||||
orgId={orgId}
|
||||
selectedPolicy={selectedPolicy}
|
||||
onSelectPolicy={(policy) => {
|
||||
onChange(policy?.resourcePolicyId ?? null);
|
||||
setSelectedLabel(
|
||||
policy
|
||||
? {
|
||||
resourcePolicyId: policy.resourcePolicyId,
|
||||
name: policy.name
|
||||
}
|
||||
: null
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5;
|
||||
type DataTableEmptyStateProps = {
|
||||
colSpan: number;
|
||||
action?: ReactNode;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function DataTableEmptyState({
|
||||
colSpan,
|
||||
action
|
||||
action,
|
||||
message
|
||||
}: DataTableEmptyStateProps) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
@@ -32,7 +34,7 @@ export function DataTableEmptyState({
|
||||
</div>
|
||||
<div className="relative flex min-h-[11rem] w-full flex-col items-center justify-center gap-4 px-4 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResults")}
|
||||
{message ?? t("noResults")}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
@@ -105,13 +105,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default gap-2 rounded-sm pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
description ? "items-start py-2" : "items-center py-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -121,7 +125,18 @@ function SelectItem({
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description ? (
|
||||
<div className="flex flex-col gap-0.5 pr-2">
|
||||
<SelectPrimitive.ItemText>
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user