Merge branch 'dev' into refactor/standardize-clear-buttons

This commit is contained in:
Fred KISSIE
2026-06-08 20:42:11 +02:00
127 changed files with 7250 additions and 10347 deletions

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyAuthenticationPage() {
return <EditPolicyForm section="authentication" />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyGeneralPage() {
return <EditPolicyForm section="general" />;
}

View File

@@ -1,5 +1,5 @@
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
@@ -9,12 +9,20 @@ import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export interface EditPolicyPageProps {
export const metadata: Metadata = {
title: "Resource Policy"
};
export const dynamic = "force-dynamic";
type EditPolicyLayoutProps = {
children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>;
}
};
export default async function EditPolicyPage(props: EditPolicyPageProps) {
export default async function EditPolicyLayout(props: EditPolicyLayoutProps) {
const params = await props.params;
const t = await getTranslations();
@@ -28,13 +36,28 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
);
policyResponse = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/policies/resource`);
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
if (!policyResponse) {
redirect(`/${params.orgId}/settings/policies/resource`);
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
const navItems = [
{
title: t("general"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/general"
},
{
title: t("authentication"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication"
},
{
title: t("policyAccessRulesTitle"),
href: "/{orgId}/settings/policies/resources/public/{niceId}/rules"
}
];
return (
<>
<div className="flex justify-between">
@@ -46,14 +69,16 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<EditPolicyForm />
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
</ResourcePolicyProvider>
</>
);

View File

@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
type EditPolicyPageProps = {
params: Promise<{ niceId: string; orgId: string }>;
};
export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
export default function EditPolicyRulesPage() {
return <EditPolicyForm section="rules" />;
}

View File

@@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage(
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>

View File

@@ -1,3 +1,4 @@
import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner";
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")}
/>
<ResourcePoliciesBanner />
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}

View File

@@ -10,7 +10,10 @@ import {
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import {
ResourceTargetAddressItem,
ResourceTargetSiteItem
} from "@app/components/resource-target-address-item";
import {
SettingsSection,
SettingsSectionBody,
@@ -18,6 +21,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import {
Table,
TableBody,
@@ -65,6 +69,7 @@ import {
useMemo,
useState
} from "react";
import { maxSize } from "zod";
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
@@ -138,11 +143,6 @@ export function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return;
@@ -207,42 +207,6 @@ export function ProxyResourceTargetsForm({
})
);
// Browser-gateway targets (edit mode only)
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
},
enabled: !!resource
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -269,7 +233,7 @@ export function ProxyResourceTargetsForm({
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 p-3">
{t("priority")}
<TooltipProvider>
<Tooltip>
@@ -285,7 +249,6 @@ export function ProxyResourceTargetsForm({
),
cell: ({ row }) => {
return (
<div className="flex items-center justify-center w-full">
<Input
type="number"
min="1"
@@ -303,7 +266,6 @@ export function ProxyResourceTargetsForm({
}
}}
/>
</div>
);
},
size: 120,
@@ -437,13 +399,12 @@ export function ProxyResourceTargetsForm({
maxSize: 200
};
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
const siteColumn: ColumnDef<LocalTarget> = {
accessorKey: "site",
header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => {
return (
<ResourceTargetAddressItem
isHttp={isHttp}
<ResourceTargetSiteItem
orgId={orgId}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
@@ -452,9 +413,26 @@ export function ProxyResourceTargetsForm({
/>
);
},
size: 400,
minSize: 350,
maxSize: 500
size: 220,
minSize: 180,
maxSize: 280
};
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
return (
<ResourceTargetAddressItem
isHttp={isHttp}
proxyTarget={row.original}
updateTarget={updateTarget}
/>
);
},
size: 350,
minSize: 300,
maxSize: 450
};
const rewritePathColumn: ColumnDef<LocalTarget> = {
@@ -567,6 +545,7 @@ export function ProxyResourceTargetsForm({
if (isAdvancedMode) {
const cols = [
siteColumn,
addressColumn,
healthCheckColumn,
enabledColumn,
@@ -575,12 +554,13 @@ export function ProxyResourceTargetsForm({
if (isHttp) {
cols.unshift(matchPathColumn);
cols.splice(3, 0, rewritePathColumn, priorityColumn);
cols.splice(4, 0, rewritePathColumn, priorityColumn);
}
return cols;
} else {
return [
siteColumn,
addressColumn,
healthCheckColumn,
enabledColumn,
@@ -603,6 +583,8 @@ export function ProxyResourceTargetsForm({
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
@@ -690,6 +672,15 @@ export function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
const addTargetButton = (
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
);
const hasTargets = targets.length > 0;
async function saveTargets() {
if (!resource) return;
@@ -803,131 +794,104 @@ export function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
const isSiteColumn =
cell.column.id ===
"site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
{t("advancedMode")}
</label>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
</div>
)}
{build === "saas" &&

View File

@@ -1,350 +1,7 @@
"use client";
import ActionBanner from "@app/components/ActionBanner";
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { build } from "@server/build";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
const resourceTypeSchema = z
.object({
type: z.literal("inline")
})
.or(
z.object({
type: z.literal("shared"),
resourcePolicyId: z.number()
})
);
type ResourcePolicyType = StrategyOption<"inline" | "shared">;
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourceAuthenticationPage() {
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext();
const queryClient = useQueryClient();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const { data: policies, isLoading: isLoadingPolicies } = useQuery(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
const form = useForm({
resolver: zodResolver(resourceTypeSchema),
defaultValues: {
type:
build !== "oss" && resource.resourcePolicyId
? "shared"
: "inline"
}
});
const selectedResourceType = useWatch({
control: form.control,
name: "type"
});
const [resourcePolicysearchQuery, setResourcePolicySearchQuery] =
useState("");
const { data: policiesList = [] } = useQuery({
...orgQueries.policies({
orgId: org.org.orgId,
name: resourcePolicysearchQuery
}),
enabled: selectedResourceType === "shared"
});
const [selectedPolicy, setSelectedPolicy] = useState<{
name: string;
id: number;
} | null>(null);
const resourcePolicyTypes: Array<ResourcePolicyType> = [
{
id: "inline",
title: t("resourcePolicyInline"),
description: t("resourcePolicyInlineDescription")
},
{
id: "shared",
title: t("resourcePolicyShared"),
description: t("resourcePolicySharedDescription")
}
];
useEffect(() => {
if (!isLoadingPolicies && policies?.sharedPolicy) {
setSelectedPolicy({
id: policies?.sharedPolicy.resourcePolicyId,
name: policies?.sharedPolicy.name
});
}
}, [isLoadingPolicies, policies?.sharedPolicy]);
const [isUpdatingResource, startTransition] = useTransition();
async function handleSaveResourcePolicyType() {
try {
if (selectedResourceType === "inline") {
await api.post(`/resource/${resource.resourceId}`, {
resourcePolicyId: null
});
} else {
if (!selectedPolicy) {
toast({
title: t("error"),
description: t("resourcePolicySelectError"),
variant: "destructive"
});
return;
}
await api.post(`/resource/${resource.resourceId}`, {
resourcePolicyId: selectedPolicy.id
});
}
router.refresh();
toast({
title: t("resourceUpdated"),
description: t("resourceUpdatedDescription")
});
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
await queryClient.invalidateQueries(
resourceQueries.policies({
resourceId: resource.resourceId
})
);
}
}
const pageLoading = isLoadingPolicies || !policies;
if (pageLoading) {
return <></>;
}
console.log({
shared: policies.sharedPolicy
});
return (
<>
<SettingsContainer>
{build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourcePolicySelectTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicySelectDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={resourcePolicyTypes}
value={selectedResourceType}
onChange={(value) => {
form.setValue("type", value);
}}
cols={2}
/>
{selectedResourceType === "shared" && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={
"w-full md:w-1/2 justify-between"
}
>
<span className="truncate max-w-37.5">
{selectedPolicy
? selectedPolicy.name
: t(
"resourcePolicySelect"
)}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command shouldFilter={false}>
<CommandInput
placeholder={t(
"resourcePolicySearch"
)}
value={
resourcePolicysearchQuery
}
onValueChange={
setResourcePolicySearchQuery
}
/>
<CommandList>
<CommandEmpty>
{t(
"resourcePolicyNotFound"
)}
</CommandEmpty>
<CommandGroup>
{policiesList.map(
(policy) => (
<CommandItem
key={
policy.resourcePolicyId
}
value={policy.resourcePolicyId.toString()}
onSelect={() =>
setSelectedPolicy(
{
id: policy.resourcePolicyId,
name: policy.name
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
policy.resourcePolicyId ===
selectedPolicy?.id
? "opacity-100"
: "opacity-0"
)}
/>
{
policy.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</SettingsSectionBody>
<SettingsSectionFooter className="justify-start">
<Button
onClick={() =>
startTransition(
handleSaveResourcePolicyType
)
}
loading={isUpdatingResource}
>
{t("resourcePolicyTypeSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{selectedResourceType === "inline" ? (
<ResourcePolicyProvider policy={policies.defaultPolicy}>
<EditPolicyForm hidePolicyNameForm />
</ResourcePolicyProvider>
) : (
policies.sharedPolicy && (
<ResourcePolicyProvider
policy={policies.sharedPolicy}
key={policies.sharedPolicy.resourcePolicyId}
>
<ActionBanner
variant="info"
title={t("resourcePolicyShared")}
titleIcon={
<ShieldAlertIcon className="w-5 h-5" />
}
description={t(
"resourcePolicySharedDescription"
)}
actions={
<Button
variant="outline"
className="gap-2"
asChild
>
<Link
href={`/${org.org.orgId}/settings/policies/resource/${policies.sharedPolicy.niceId}`}
>
{t("editSharedPolicy")}
<ArrowRightIcon className="size-4" />
</Link>
</Button>
}
/>
<EditPolicyForm
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider>
)
)}
</SettingsContainer>
</>
);
return <ResourcePolicyEditForm section="authentication" />;
}

View File

@@ -36,10 +36,14 @@ import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode";
import { useActionState, useMemo, useState } from "react";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { SharedPolicySelect } from "@app/components/shared-policy-selector";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { TierFeature } from "@server/lib/billing/tierMatrix";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
@@ -220,6 +224,11 @@ function MaintenanceSectionForm({
</TooltipProvider>
</FormControl>
</div>
<FormDescription>
{t(
"enableMaintenanceModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
@@ -429,16 +438,30 @@ function MaintenanceSectionForm({
export default function GeneralForm() {
const params = useParams();
const { org } = useOrgContext();
const { resource, updateResource } = useResourceContext();
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const orgId = params.orgId;
const api = createApiClient({ env });
const showResourcePolicy =
build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
const [selectedSharedPolicyId, setSelectedSharedPolicyId] = useState<
number | null
>(resource.resourcePolicyId ?? null);
useEffect(() => {
setSelectedSharedPolicyId(resource.resourcePolicyId ?? null);
}, [resource.resourcePolicyId]);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
@@ -501,6 +524,12 @@ export default function GeneralForm() {
const data = form.getValues();
let resourcePolicyId: number | null | undefined;
if (showResourcePolicy) {
resourcePolicyId = selectedSharedPolicyId;
}
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
@@ -514,7 +543,8 @@ export default function GeneralForm() {
)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
...(resourcePolicyId !== undefined && { resourcePolicyId })
}
)
.catch((e) => {
@@ -538,7 +568,10 @@ export default function GeneralForm() {
subdomain: data.subdomain,
fullDomain: updated.fullDomain,
proxyPort: data.proxyPort,
domainId: data.domainId
domainId: data.domainId,
...(resourcePolicyId !== undefined && {
resourcePolicyId
})
});
toast({
@@ -579,13 +612,47 @@ export default function GeneralForm() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -732,40 +799,24 @@ export default function GeneralForm() {
</div>
</div>
)}
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{showResourcePolicy && (
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
}
orgId={org.org.orgId}
value={selectedSharedPolicyId}
onChange={
setSelectedSharedPolicyId
}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
];
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
navItems.push({
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
});
navItems.push(
{
title: t("authentication"),
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
},
{
title: t("policyAccessRulesTitle"),
href: `/{orgId}/settings/resources/public/{niceId}/rules`
}
);
}
return (

View File

@@ -11,201 +11,183 @@ import {
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
};
export default function SshSettingsPage(props: {
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function RdpSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
<RdpServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
}
function SshServerForm({
function RdpServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
const firstTarget = targets[0];
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
const formSchema = useMemo(
() => createBrowserGatewayTargetFormSchema(t),
[t]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
const form = useForm<BrowserGatewayTargetFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
selectedSites: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "3389"
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const { selectedSites, destination, destinationPort } =
form.getValues();
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "rdp",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "rdp",
ip: destination,
port: Number(destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
toast({
title: t("settingsUpdated"),
@@ -237,31 +219,31 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -0,0 +1,7 @@
"use client";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
export default function ResourcePolicyRulesPage() {
return <ResourcePolicyEditForm section="rules" />;
}

View File

@@ -15,10 +15,7 @@ import {
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SitesSelector,
type Selectedsite
} from "@app/components/site-selector";
import { SitesSelector } from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button";
@@ -41,33 +38,37 @@ import { Badge } from "@app/components/ui/badge";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createSshSettingsFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
};
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
@@ -75,10 +76,23 @@ export default function SshSettingsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
@@ -89,6 +103,7 @@ export default function SshSettingsPage(props: {
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
@@ -98,232 +113,235 @@ function SshServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
const firstTarget = targets[0];
const initialPamMode =
(resource.pamMode as "passthrough" | "push") || "passthrough";
const initialStandardDaemonLocation = isNativeInitially
? "site"
: ((resource.authDaemonMode as "site" | "remote") || "site");
const useSingleSiteOnLoad =
!isNativeInitially &&
initialPamMode === "push" &&
initialStandardDaemonLocation === "site";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
const [sshServerMode] = useState<"standard" | "native">(
isNativeInitially ? "native" : "standard"
);
const isNative = sshServerMode === "native";
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough"
const formSchema = useMemo(
() => createSshSettingsFormSchema(t, { isNative }),
[t, isNative]
);
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
"site" | "remote"
>(
isNativeInitially
? "site"
: (resource.authDaemonMode as "site" | "remote") || "site"
);
const form = useForm({
resolver: zodResolver(sshFormSchema),
const form = useForm<SshSettingsFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
authDaemonPort: (resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
pamMode: initialPamMode,
standardDaemonLocation: initialStandardDaemonLocation,
authDaemonPort: (resource as { authDaemonPort?: number })
.authDaemonPort
? String((resource as { authDaemonPort?: number }).authDaemonPort)
: "22123",
selectedSites:
isNativeInitially || useSingleSiteOnLoad
? []
: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
selectedSite:
useSingleSiteOnLoad && firstTarget
? {
siteId: firstTarget.siteId,
name:
firstTarget.siteName ??
String(firstTarget.siteId),
type: "newt" as const
}
: null,
selectedNativeSite:
isNativeInitially && firstTarget
? {
siteId: firstTarget.siteId,
name:
firstTarget.siteName ??
String(firstTarget.siteId),
type: "newt" as const
}
: null,
destination: isNativeInitially
? ""
: (firstTarget?.ip ?? ""),
destinationPort: isNativeInitially
? "22"
: firstTarget
? String(firstTarget.port)
: "22"
}
});
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
() =>
isNativeInitially
? []
: targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId,
}))
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
useState<ExistingTarget | null>(() =>
isNativeInitially && firstTarget
? {
targetId: firstTarget.targetId,
siteId: firstTarget.siteId,
}
: null
);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
if (isNativeInitially) {
setSelectedNativeSite({
siteId: first.siteId,
name: first.siteName ?? String(first.siteId),
type: "newt" as const
});
setNativeExistingTarget({
browserGatewayTargetId: first.browserGatewayTargetId,
siteId: first.siteId
});
} else {
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
const pamMode = form.watch("pamMode");
const standardDaemonLocation = form.watch("standardDaemonLocation");
const selectedNativeSite = form.watch("selectedNativeSite");
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const effectiveMode = isNative ? "native" : standardDaemonLocation;
const portVal = form.getValues().authDaemonPort;
const values = form.getValues();
const effectiveMode = isNative ? "native" : values.standardDaemonLocation;
const effectivePort =
!isNative && standardDaemonLocation === "remote" && portVal
? Number(portVal)
!isNative &&
values.standardDaemonLocation === "remote" &&
values.authDaemonPort
? Number(values.authDaemonPort)
: null;
try {
await api.post(`/resource/${resource.resourceId}`, {
pamMode,
pamMode: values.pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort
});
updateResource({
...resource,
pamMode,
pamMode: values.pamMode,
authDaemonMode: effectiveMode
});
if (isNative) {
if (selectedNativeSite) {
const nativeSite = values.selectedNativeSite;
if (nativeSite) {
if (nativeExistingTarget) {
await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
`/target/${nativeExistingTarget.targetId}`,
{
type: "ssh",
destination: "localhost",
destinationPort: 22,
siteId: selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
siteId: nativeSite.siteId,
hcEnabled: false
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: selectedNativeSite.siteId
...nativeExistingTarget,
siteId: nativeSite.siteId
});
} else {
const res = await api.put(
`/resource/${resource.resourceId}/target`,
{
siteId: nativeSite.siteId,
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
setNativeExistingTarget({
targetId: res.data.data.targetId,
siteId: nativeSite.siteId,
});
}
}
} else {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const useMultiSite =
values.standardDaemonLocation !== "site" ||
values.pamMode === "passthrough";
const activeSites = useMultiSite
? values.selectedSites
: values.selectedSite
? [values.selectedSite]
: [];
const selectedSiteIds = new Set(
activeSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) => api.delete(`/target/${t.targetId}`))
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map(
(res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
siteId: t.siteId,
hcEnabled: false
})
);
setExistingTargets([...toUpdate, ...newTargets]);
}
)
);
const toCreate = activeSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId,
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
@@ -373,6 +391,9 @@ function SshServerForm({
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const useMultiSiteTargetForm =
!isNative &&
(standardDaemonLocation !== "site" || pamMode === "passthrough");
return (
<SettingsSection>
@@ -386,160 +407,189 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</SettingsSubsectionTitle>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<div className="space-y-3">
<SettingsSubsectionTitle>
{t("sshAuthenticationMethod")}
</SettingsSubsectionTitle>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={setPamMode}
cols={2}
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
cols={2}
/>
</div>
{showDaemonLocation && (
<div className="space-y-3">
<SettingsSubsectionTitle>
{t("sshAuthDaemonLocation")}
</SettingsSubsectionTitle>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={setStandardDaemonLocation}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<Form {...form}>
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
onOpenChange={setNativeSiteOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.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={orgId}
selectedSite={selectedNativeSite}
onSelectSite={(site) => {
setSelectedNativeSite(site);
setNativeSiteOpen(false);
}}
{showDaemonLocation && (
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{ shouldValidate: true }
)
}
cols={2}
/>
</PopoverContent>
</Popover>
) : standardDaemonLocation !== "site" ||
pamMode === "passthrough" ? (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={false}
selectedSite={selectedSite}
onSiteChange={setSelectedSite}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<div className="w-full md:w-1/2">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.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">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
) : useMultiSiteTargetForm ? (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -11,199 +11,183 @@ import {
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { z } from "zod";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
};
export default function SshSettingsPage(props: {
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function VncSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
<VncServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
}
function SshServerForm({
function VncServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
const firstTarget = targets[0];
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
const formSchema = useMemo(
() => createBrowserGatewayTargetFormSchema(t),
[t]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
const form = useForm<BrowserGatewayTargetFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
selectedSites: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "5900"
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const { selectedSites, destination, destinationPort } =
form.getValues();
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "vnc",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "vnc",
ip: destination,
port: Number(destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
toast({
title: t("settingsUpdated"),
@@ -235,31 +219,31 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -50,6 +50,12 @@ import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createBrowserGatewayTargetFormSchema,
createSshSettingsFormSchema,
selectedSiteSchema,
type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
@@ -79,100 +85,134 @@ import {
useTransition,
useEffect
} from "react";
import { useForm } from "react-hook-form";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
http: z.boolean()
});
type TranslateFn = (key: string) => string;
const httpResourceFormSchema = z.object({
domainId: z.string().nonempty(),
subdomain: z.string().optional()
});
function createBaseResourceFormSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.min(1, { message: t("nameRequired") })
.max(255, {
message: t("createInternalResourceDialogNameMaxLength")
}),
http: z.boolean()
});
}
const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
proxyPort: z.int().min(1).max(65535)
});
function createHttpResourceFormSchema(t: TranslateFn) {
return z.object({
domainId: z.string().min(1, { message: t("domainRequired") }),
subdomain: z.string().optional()
});
}
const sshDaemonPortSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
function createTcpUdpResourceFormSchema(t: TranslateFn) {
return z.object({
protocol: z.string(),
proxyPort: z
.number({ error: t("proxyPortRequired") })
.int({ error: t("healthCheckPortInvalid") })
.min(1, { message: t("healthCheckPortInvalid") })
.max(65535, { message: t("healthCheckPortInvalid") })
});
}
const addTargetSchema = z
.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number<number>().int().positive(),
siteId: z.int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.int().min(1).max(1000).optional()
})
.refine(
(data) => {
if (data.path && !data.pathMatchType) {
return false;
}
if (data.pathMatchType && !data.path) {
return false;
}
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
return data.path.startsWith("/");
case "regex":
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
error: "Invalid path configuration"
}
)
.refine(
(data) => {
if (data.rewritePath && !data.rewritePathType) {
return false;
}
if (data.rewritePathType && !data.rewritePath) {
if (data.rewritePathType !== "stripPrefix") {
function createSshDaemonPortSchema(t: TranslateFn) {
return z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: t("healthCheckPortInvalid") }
)
});
}
function createAddTargetSchema(t: TranslateFn) {
return z
.object({
ip: z.string().refine(isTargetValid, {
message: t("targetErrorInvalidIpDescription")
}),
method: z.string().nullable(),
port: z.coerce
.number<number>({ error: t("targetErrorInvalidPortDescription") })
.int({ error: t("targetErrorInvalidPortDescription") })
.positive({ error: t("targetErrorInvalidPortDescription") }),
siteId: z
.int({ error: t("siteRequired") })
.positive({ error: t("siteRequired") }),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z
.int()
.min(1, { message: t("healthCheckPortInvalid") })
.max(1000, { message: t("healthCheckPortInvalid") })
.optional()
})
.refine(
(data) => {
if (data.path && !data.pathMatchType) {
return false;
}
if (data.pathMatchType && !data.path) {
return false;
}
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
return data.path.startsWith("/");
case "regex":
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: t("invalidPathConfiguration")
}
return true;
},
{
error: "Invalid rewrite path configuration"
}
);
)
.refine(
(data) => {
if (data.rewritePath && !data.rewritePathType) {
return false;
}
if (data.rewritePathType && !data.rewritePath) {
if (data.rewritePathType !== "stripPrefix") {
return false;
}
}
return true;
},
{
message: t("invalidRewritePathConfiguration")
}
);
}
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
type CreateBgTargetFormValues = SshSettingsFormValues;
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -223,29 +263,6 @@ export default function Page() {
useState<Selectedsite | null>(null);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
// Browser-gateway targets state (SSH standard, RDP, VNC)
const [bgSelectedSites, setBgSelectedSites] = useState<Selectedsite[]>([]);
const [bgSelectedSite, setBgSelectedSite] = useState<Selectedsite | null>(
null
);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
// Reset BG state when resource type changes
useEffect(() => {
if (resourceType === "rdp") {
setBgDestinationPort("3389");
} else if (resourceType === "vnc") {
setBgDestinationPort("5900");
} else if (resourceType === "ssh") {
setBgDestinationPort("22");
}
setBgDestination("");
setBgSelectedSites([]);
setBgSelectedSite(null);
setNativeSelectedSite(null);
}, [resourceType]);
useEffect(() => {
if (build !== "saas") return;
@@ -278,6 +295,39 @@ export default function Page() {
pamMode === "push" &&
standardDaemonLocation === "remote";
const bgTargetFormSchema = useMemo(() => {
if (resourceType === "ssh" && !isNative) {
return createSshSettingsFormSchema(t, { isNative: false });
}
if (resourceType === "rdp" || resourceType === "vnc") {
return createBrowserGatewayTargetFormSchema(t);
}
return z.object({
selectedSites: z.array(selectedSiteSchema),
selectedSite: selectedSiteSchema.nullable(),
destination: z.string(),
destinationPort: z.string(),
pamMode: z.enum(["passthrough", "push"]),
standardDaemonLocation: z.enum(["site", "remote"])
});
}, [resourceType, isNative, t]);
const bgTargetForm = useForm<CreateBgTargetFormValues>({
resolver: zodResolver(
bgTargetFormSchema
) as unknown as Resolver<CreateBgTargetFormValues>,
defaultValues: {
selectedSites: [],
selectedSite: null,
selectedNativeSite: null,
destination: "",
destinationPort: "22",
pamMode: "passthrough",
standardDaemonLocation: "site",
authDaemonPort: "22123"
}
});
// Whether raw (TCP/UDP) resources are available
const rawResourcesAllowed =
env.flags.allowRawResources &&
@@ -302,6 +352,24 @@ export default function Page() {
}
}, [availableTypes, resourceType]);
const baseResourceFormSchema = useMemo(
() => createBaseResourceFormSchema(t),
[t]
);
const httpResourceFormSchema = useMemo(
() => createHttpResourceFormSchema(t),
[t]
);
const tcpUdpResourceFormSchema = useMemo(
() => createTcpUdpResourceFormSchema(t),
[t]
);
const sshDaemonPortSchema = useMemo(
() => createSshDaemonPortSchema(t),
[t]
);
const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]);
const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema),
defaultValues: {
@@ -330,6 +398,31 @@ export default function Page() {
}
});
useEffect(() => {
const defaultPort =
resourceType === "rdp"
? "3389"
: resourceType === "vnc"
? "5900"
: "22";
bgTargetForm.reset({
selectedSites: [],
selectedSite: null,
selectedNativeSite: null,
destination: "",
destinationPort: defaultPort,
pamMode,
standardDaemonLocation,
authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort
});
setNativeSelectedSite(null);
}, [resourceType]);
useEffect(() => {
bgTargetForm.setValue("pamMode", pamMode);
bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation);
}, [pamMode, standardDaemonLocation]);
// Sync form http field with resourceType
useEffect(() => {
baseForm.setValue("http", isHttpResource);
@@ -498,30 +591,35 @@ export default function Page() {
if (isNative) {
if (nativeSelectedSite) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: nativeSelectedSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
}
} else {
const sitesToCreate =
standardDaemonLocation !== "site"
? bgSelectedSites
: bgSelectedSite
? [bgSelectedSite]
: [];
const bgValues = bgTargetForm.getValues();
const useMultiSite =
standardDaemonLocation !== "site" ||
pamMode === "passthrough";
const sitesToCreate = useMultiSite
? bgValues.selectedSites
: bgValues.selectedSite
? [bgValues.selectedSite]
: [];
for (const site of sitesToCreate) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
mode: "ssh",
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
hcEnabled: false
}
);
}
@@ -531,16 +629,18 @@ export default function Page() {
`/${orgId}/settings/resources/public/${newNiceId}`
);
} else if (resourceType === "rdp" || resourceType === "vnc") {
for (const site of bgSelectedSites) {
const bgValues = bgTargetForm.getValues();
for (const site of bgValues.selectedSites) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: resourceType,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
mode: resourceType,
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
hcEnabled: false
}
);
);
}
router.push(
@@ -760,32 +860,56 @@ export default function Page() {
{/* Domain/Subdomain (HTTP-based types) */}
{isHttpResource && (
<div className="space-y-2">
<DomainPicker
allowWildcard={true}
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
}}
/>
<p className="text-sm text-muted-foreground">
{t(
"resourceDomainDescription"
<Form {...httpForm}>
<FormField
control={httpForm.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (!res)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
</p>
</div>
/>
</Form>
)}
{/* Proxy Port (TCP/UDP types) */}
@@ -883,9 +1007,7 @@ export default function Page() {
<SettingsSectionForm variant="half">
{/* Mode */}
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</SettingsSubsectionTitle>
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<StrategySelect<
"standard" | "native"
>
@@ -897,11 +1019,7 @@ export default function Page() {
</div>
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthenticationMethod"
)}
</SettingsSubsectionTitle>
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<
"passthrough" | "push"
>
@@ -917,11 +1035,7 @@ export default function Page() {
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthDaemonLocation"
)}
</SettingsSubsectionTitle>
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<
"site" | "remote"
>
@@ -1052,55 +1166,39 @@ export default function Page() {
"site" ||
pamMode ===
"passthrough" ? (
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={
bgSelectedSites
}
onSitesChange={
setBgSelectedSites
}
destination={
bgDestination
}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
) : (
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={false}
selectedSite={
bgSelectedSite
}
onSiteChange={
setBgSelectedSite
}
destination={
bgDestination
}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
)}
</div>
</SettingsSectionForm>
@@ -1138,26 +1236,18 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={bgSelectedSites}
onSitesChange={
setBgSelectedSites
}
destination={bgDestination}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={bgTargetForm.control}
orgId={orgId as string}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
@@ -1193,26 +1283,18 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={bgSelectedSites}
onSitesChange={
setBgSelectedSites
}
destination={bgDestination}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={bgTargetForm.control}
orgId={orgId as string}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
@@ -1253,15 +1335,31 @@ export default function Page() {
const tcpValid = !isHttpResource
? await tcpUdpForm.trigger()
: true;
const sshPortValid = showDaemonPort
? await sshDaemonPortForm.trigger()
: true;
if (
resourceType === "ssh" &&
!isNative
) {
bgTargetForm.setValue(
"authDaemonPort",
sshDaemonPortForm.getValues()
.authDaemonPort
);
}
const bgValid =
resourceType === "rdp" ||
resourceType === "vnc" ||
(resourceType === "ssh" &&
!isNative)
? await bgTargetForm.trigger()
: true;
if (
baseValid &&
domainValid &&
tcpValid &&
sshPortValid
bgValid
) {
onSubmit();
}

View File

@@ -1,7 +1,7 @@
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
import type { ResourceRow } from "@app/components/PublicResourcesTable";
import PublicResourcesTable from "@app/components/PublicResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
@@ -146,10 +146,10 @@ export default async function ProxyResourcesPage(
description={t("proxyResourceDescription")}
/>
<ProxyResourcesBanner />
<PublicResourcesBanner />
<OrgProvider org={org}>
<ProxyResourcesTable
<PublicResourcesTable
resources={resourceRows}
orgId={params.orgId}
rowCount={pagination.total}

View File

@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--border: oklch(0.88 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 8%);
--border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);

View File

@@ -146,7 +146,7 @@ export const orgNavSections = (
items: [
{
title: "sidebarResourcePolicies",
href: "/{orgId}/settings/policies/resource",
href: "/{orgId}/settings/policies/resources/public",
icon: (
<GlobeIcon className="size-4 flex-none" />
)

View File

@@ -35,7 +35,12 @@ import {
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
declare module "react" {
namespace JSX {
@@ -62,22 +67,14 @@ type RdpCredentialsForm = {
enableClipboard: boolean;
};
function loadStoredCredentials(key: string): RdpCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
}
const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
const isIronError = (error: unknown): error is IronError => {
return (
@@ -112,9 +109,25 @@ export default function RdpClient({
const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_RDP_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<RdpCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
@@ -292,11 +305,11 @@ export default function RdpClient({
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
values,
target.authToken
);
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
@@ -443,6 +456,7 @@ export default function RdpClient({
</Form>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}

View File

@@ -31,6 +31,11 @@ import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type AuthTab = "password" | "privateKey";
@@ -47,15 +52,11 @@ type ConnectCredentials = {
certificate?: string;
};
function loadStoredCredentials(key: string): SshCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as SshCredentialsForm;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
}
const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
username: "",
password: "",
privateKey: ""
};
export default function SshClient({
target,
@@ -85,9 +86,25 @@ export default function SshClient({
});
const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_SSH_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<SshCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
@@ -251,14 +268,11 @@ export default function SshClient({
})
);
if (!override) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(form.getValues())
);
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
form.getValues(),
target.authToken
);
}
};
@@ -618,12 +632,13 @@ export default function SshClient({
</Form>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}
{connected && (
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
{/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
@@ -631,7 +646,7 @@ export default function SshClient({
>
{t("sshTerminate")}
</Button>
</div>
</div> */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden"

View File

@@ -26,21 +26,20 @@ import {
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type VncCredentialsForm = {
password: string;
};
function loadStoredCredentials(key: string): VncCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as VncCredentialsForm;
} catch {
// ignore
}
return { password: "" };
}
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
password: ""
};
export default function VncClient({
target,
@@ -61,9 +60,25 @@ export default function VncClient({
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_VNC_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<VncCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null);
@@ -131,11 +146,11 @@ export default function VncClient({
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
values,
target.authToken
);
setConnected(true);
});
@@ -242,6 +257,7 @@ export default function VncClient({
</Form>
</CardContent>
</Card>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -127,7 +127,7 @@ const booleanSearchFilterSchema = z
.optional()
.catch(undefined);
export default function ProxyResourcesTable({
export default function PublicResourcesTable({
resources,
orgId,
pagination,

View File

@@ -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 />

View File

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

View 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;

View File

@@ -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`
)
)
}

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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

View File

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

View File

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

View 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];
}

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

View 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()
});

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

View File

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

View 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>
);
}

View File

@@ -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: {

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
import { z } from "zod";
type TranslateFn = (key: string) => string;
export const selectedSiteSchema = z.object({
siteId: z.number().int().positive(),
name: z.string(),
type: z.string()
});
export type SelectedSiteFormValue = z.infer<typeof selectedSiteSchema>;
export function createPortStringSchema(t: TranslateFn) {
return z.string().refine(
(val) => {
if (!val) return false;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: t("healthCheckPortInvalid") }
);
}
function createOptionalAuthDaemonPortSchema(t: TranslateFn) {
return z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: t("healthCheckPortInvalid") }
);
}
export function createBrowserGatewayTargetFormSchema(t: TranslateFn) {
return z.object({
selectedSites: z.array(selectedSiteSchema).min(1, {
message: t("siteRequired")
}),
destination: z.string().min(1, {
message: t("destinationRequired")
}),
destinationPort: createPortStringSchema(t)
});
}
export type BrowserGatewayTargetFormValues = z.infer<
ReturnType<typeof createBrowserGatewayTargetFormSchema>
>;
export function createSshSettingsFormSchema(
t: TranslateFn,
options: { isNative: boolean }
) {
const { isNative } = options;
const portSchema = createPortStringSchema(t);
const optionalAuthDaemonPortSchema = createOptionalAuthDaemonPortSchema(t);
return z
.object({
pamMode: z.enum(["passthrough", "push"]),
standardDaemonLocation: z.enum(["site", "remote"]),
authDaemonPort: z.string(),
selectedSites: z.array(selectedSiteSchema),
selectedSite: selectedSiteSchema.nullable(),
selectedNativeSite: selectedSiteSchema.nullable(),
destination: z.string(),
destinationPort: z.string()
})
.superRefine((data, ctx) => {
if (isNative) {
if (!data.selectedNativeSite) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["selectedNativeSite"],
message: t("siteRequired")
});
}
return;
}
const useMultiSite =
data.standardDaemonLocation !== "site" ||
data.pamMode === "passthrough";
if (useMultiSite) {
if (data.selectedSites.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["selectedSites"],
message: t("siteRequired")
});
}
} else if (!data.selectedSite) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["selectedSite"],
message: t("siteRequired")
});
}
if (!data.destination.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["destination"],
message: t("destinationRequired")
});
}
const portResult = portSchema.safeParse(data.destinationPort);
if (!portResult.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["destinationPort"],
message: t("healthCheckPortInvalid")
});
}
const showDaemonPort =
data.pamMode === "push" &&
data.standardDaemonLocation === "remote";
if (showDaemonPort) {
const authPortResult = optionalAuthDaemonPortSchema.safeParse(
data.authDaemonPort
);
if (!data.authDaemonPort.trim() || !authPortResult.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["authDaemonPort"],
message: t("healthCheckPortInvalid")
});
}
}
});
}
export type SshSettingsFormValues = z.infer<
ReturnType<typeof createSshSettingsFormSchema>
>;

View File

@@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
export type ProductUpdate = {
link: string | null;
@@ -581,16 +582,16 @@ export const orgQueries = {
}
}),
policies: ({ orgId, name }: { orgId: string; name?: string }) =>
policies: ({ orgId, query }: { orgId: string; query?: string }) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const,
queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: "10"
});
if (name) {
sp.set("query", name);
if (query) {
sp.set("query", query);
}
const res = await meta!.api.get<
@@ -601,6 +602,18 @@ export const orgQueries = {
return res.data.data.policies;
}
}),
resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) =>
queryOptions({
queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<GetResourcePolicyResponse>
>(`/resource-policy/${resourcePolicyId}`, { signal });
return res.data.data;
}
})
};

View File

@@ -0,0 +1,124 @@
type EncryptedStorageEnvelope = {
v: 1;
s: string;
i: string;
d: string;
};
const PBKDF2_ITERATIONS = 120000;
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function deriveKey(authToken: string, salt: ArrayBuffer) {
const subtle = window.crypto?.subtle;
if (!subtle) {
throw new Error("Web Crypto is unavailable");
}
const tokenKey = await subtle.importKey(
"raw",
toArrayBuffer(new TextEncoder().encode(authToken)),
"PBKDF2",
false,
["deriveKey"]
);
return subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256"
},
tokenKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function saveEncryptedLocalStorage<T>(
storageKey: string,
value: T,
authToken: string | null | undefined
) {
if (typeof window === "undefined") return;
if (!authToken) {
window.localStorage.removeItem(storageKey);
return;
}
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(authToken, toArrayBuffer(salt));
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const encrypted = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(plaintext)
);
const payload: EncryptedStorageEnvelope = {
v: 1,
s: bytesToBase64(salt),
i: bytesToBase64(iv),
d: bytesToBase64(new Uint8Array(encrypted))
};
window.localStorage.setItem(storageKey, JSON.stringify(payload));
}
export async function loadEncryptedLocalStorage<T>(
storageKey: string,
authToken: string | null | undefined
): Promise<T | null> {
if (typeof window === "undefined") return null;
if (!authToken) return null;
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
try {
const payload = JSON.parse(raw) as EncryptedStorageEnvelope;
if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) {
throw new Error("Invalid encrypted payload");
}
const salt = base64ToBytes(payload.s);
const iv = base64ToBytes(payload.i);
const data = base64ToBytes(payload.d);
const key = await deriveKey(authToken, toArrayBuffer(salt));
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(data)
);
const json = new TextDecoder().decode(decrypted);
return JSON.parse(json) as T;
} catch {
window.localStorage.removeItem(storageKey);
return null;
}
}