move edit resource to proxy subpath

This commit is contained in:
miloschwartz
2025-12-04 21:18:17 -05:00
parent 54670e150d
commit 2bad9daaea
11 changed files with 20 additions and 20 deletions

View File

@@ -0,0 +1,996 @@
"use client";
import { useEffect, useState } from "react";
import { ListRolesResponse } from "@server/routers/role";
import { toast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import {
GetResourceWhitelistResponse,
ListResourceRolesResponse,
ListResourceUsersResponse
} from "@server/routers/resource";
import { Button } from "@app/components/ui/button";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { ListUsersResponse } from "@server/routers/user";
import { Binary, Key, Bot } from "lucide-react";
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
SettingsContainer,
SettingsSection,
SettingsSectionTitle,
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Separator } from "@app/components/ui/separator";
import { build } from "@server/build";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { TierId } from "@server/lib/billing/tiers";
const UsersRolesFormSchema = z.object({
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
),
users: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
const whitelistSchema = z.object({
emails: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
export default function ResourceAuthenticationPage() {
const { org } = useOrgContext();
const { resource, updateResource, authInfo, updateAuthInfo } =
useResourceContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const [pageLoading, setPageLoading] = useState(true);
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
[]
);
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>(
[]
);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
// const [blockAccess, setBlockAccess] = useState(resource.blockAccess);
const [whitelistEnabled, setWhitelistEnabled] = useState(
resource.emailWhitelistEnabled
);
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
);
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
resource.skipToIdpId || null
);
const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]);
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false);
const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] =
useState(false);
const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] =
useState(false);
const [
loadingRemoveResourceHeaderAuth,
setLoadingRemoveResourceHeaderAuth
] = useState(false);
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
const usersRolesForm = useForm({
resolver: zodResolver(UsersRolesFormSchema),
defaultValues: { roles: [], users: [] }
});
const whitelistForm = useForm({
resolver: zodResolver(whitelistSchema),
defaultValues: { emails: [] }
});
useEffect(() => {
const fetchData = async () => {
try {
const [
rolesResponse,
resourceRolesResponse,
usersResponse,
resourceUsersResponse,
whitelist,
idpsResponse
] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
),
api.get<AxiosResponse<ListResourceRolesResponse>>(
`/resource/${resource.resourceId}/roles`
),
api.get<AxiosResponse<ListUsersResponse>>(
`/org/${org?.org.orgId}/users`
),
api.get<AxiosResponse<ListResourceUsersResponse>>(
`/resource/${resource.resourceId}/users`
),
api.get<AxiosResponse<GetResourceWhitelistResponse>>(
`/resource/${resource.resourceId}/whitelist`
),
api.get<
AxiosResponse<{
idps: { idpId: number; name: string }[];
}>
>(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp")
]);
setAllRoles(
rolesResponse.data.data.roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin")
);
usersRolesForm.setValue(
"roles",
resourceRolesResponse.data.data.roles
.map((i) => ({
id: i.roleId.toString(),
text: i.name
}))
.filter((role) => role.text !== "Admin")
);
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
usersRolesForm.setValue(
"users",
resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);
whitelistForm.setValue(
"emails",
whitelist.data.data.whitelist.map((w) => ({
id: w.email,
text: w.email
}))
);
if (build === "saas") {
if (subscription?.subscribed) {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,
text: idp.name
}))
);
}
} else {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,
text: idp.name
}))
);
}
if (
autoLoginEnabled &&
!selectedIdpId &&
idpsResponse.data.data.idps.length > 0
) {
setSelectedIdpId(idpsResponse.data.data.idps[0].idpId);
}
setPageLoading(false);
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t("resourceErrorAuthFetch"),
description: formatAxiosError(
e,
t("resourceErrorAuthFetchDescription")
)
});
}
};
fetchData();
}, []);
async function saveWhitelist() {
setLoadingSaveWhitelist(true);
try {
await api.post(`/resource/${resource.resourceId}`, {
emailWhitelistEnabled: whitelistEnabled
});
if (whitelistEnabled) {
await api.post(`/resource/${resource.resourceId}/whitelist`, {
emails: whitelistForm.getValues().emails.map((i) => i.text)
});
}
updateResource({
emailWhitelistEnabled: whitelistEnabled
});
toast({
title: t("resourceWhitelistSave"),
description: t("resourceWhitelistSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t("resourceErrorWhitelistSave"),
description: formatAxiosError(
e,
t("resourceErrorWhitelistSaveDescription")
)
});
} finally {
setLoadingSaveWhitelist(false);
}
}
async function onSubmitUsersRoles(
data: z.infer<typeof UsersRolesFormSchema>
) {
try {
setLoadingSaveUsersRoles(true);
// Validate that an IDP is selected if auto login is enabled
if (autoLoginEnabled && !selectedIdpId) {
toast({
variant: "destructive",
title: t("error"),
description: t("selectIdpRequired")
});
return;
}
const jobs = [
api.post(`/resource/${resource.resourceId}/roles`, {
roleIds: data.roles.map((i) => parseInt(i.id))
}),
api.post(`/resource/${resource.resourceId}/users`, {
userIds: data.users.map((i) => i.id)
}),
api.post(`/resource/${resource.resourceId}`, {
sso: ssoEnabled,
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
})
];
await Promise.all(jobs);
updateResource({
sso: ssoEnabled,
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
});
updateAuthInfo({
sso: ssoEnabled
});
toast({
title: t("resourceAuthSettingsSave"),
description: t("resourceAuthSettingsSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t("resourceErrorUsersRolesSave"),
description: formatAxiosError(
e,
t("resourceErrorUsersRolesSaveDescription")
)
});
} finally {
setLoadingSaveUsersRoles(false);
}
}
function removeResourcePassword() {
setLoadingRemoveResourcePassword(true);
api.post(`/resource/${resource.resourceId}/password`, {
password: null
})
.then(() => {
toast({
title: t("resourcePasswordRemove"),
description: t("resourcePasswordRemoveDescription")
});
updateAuthInfo({
password: false
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorPasswordRemove"),
description: formatAxiosError(
e,
t("resourceErrorPasswordRemoveDescription")
)
});
})
.finally(() => setLoadingRemoveResourcePassword(false));
}
function removeResourcePincode() {
setLoadingRemoveResourcePincode(true);
api.post(`/resource/${resource.resourceId}/pincode`, {
pincode: null
})
.then(() => {
toast({
title: t("resourcePincodeRemove"),
description: t("resourcePincodeRemoveDescription")
});
updateAuthInfo({
pincode: false
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorPincodeRemove"),
description: formatAxiosError(
e,
t("resourceErrorPincodeRemoveDescription")
)
});
})
.finally(() => setLoadingRemoveResourcePincode(false));
}
function removeResourceHeaderAuth() {
setLoadingRemoveResourceHeaderAuth(true);
api.post(`/resource/${resource.resourceId}/header-auth`, {
user: null,
password: null
})
.then(() => {
toast({
title: t("resourceHeaderAuthRemove"),
description: t("resourceHeaderAuthRemoveDescription")
});
updateAuthInfo({
headerAuth: false
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorHeaderAuthRemove"),
description: formatAxiosError(
e,
t("resourceErrorHeaderAuthRemoveDescription")
)
});
})
.finally(() => setLoadingRemoveResourceHeaderAuth(false));
}
if (pageLoading) {
return <></>;
}
return (
<>
{isSetPasswordOpen && (
<SetResourcePasswordForm
open={isSetPasswordOpen}
setOpen={setIsSetPasswordOpen}
resourceId={resource.resourceId}
onSetPassword={() => {
setIsSetPasswordOpen(false);
updateAuthInfo({
password: true
});
}}
/>
)}
{isSetPincodeOpen && (
<SetResourcePincodeForm
open={isSetPincodeOpen}
setOpen={setIsSetPincodeOpen}
resourceId={resource.resourceId}
onSetPincode={() => {
setIsSetPincodeOpen(false);
updateAuthInfo({
pincode: true
});
}}
/>
)}
{isSetHeaderAuthOpen && (
<SetResourceHeaderAuthForm
open={isSetHeaderAuthOpen}
setOpen={setIsSetHeaderAuthOpen}
resourceId={resource.resourceId}
onSetHeaderAuth={() => {
setIsSetHeaderAuthOpen(false);
updateAuthInfo({
headerAuth: true
});
}}
/>
)}
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceUsersRoles")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceUsersRolesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.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={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.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={usersRolesForm.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"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.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="mt-8">
<div className="space-y-2 mb-3">
<CheckboxWithLabel
label={t(
"autoLoginExternalIdp"
)}
checked={autoLoginEnabled}
onCheckedChange={(
checked
) => {
setAutoLoginEnabled(
checked as boolean
);
if (
checked &&
allIdps.length > 0
) {
setSelectedIdpId(
allIdps[0].id
);
} else {
setSelectedIdpId(
null
);
}
}}
/>
<p className="text-sm text-muted-foreground">
{t(
"autoLoginExternalIdpDescription"
)}
</p>
</div>
{autoLoginEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("selectIdp")}
</label>
<Select
onValueChange={(
value
) =>
setSelectedIdpId(
parseInt(value)
)
}
value={
selectedIdpId
? selectedIdpId.toString()
: undefined
}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectIdpPlaceholder"
)}
/>
</SelectTrigger>
<SelectContent>
{allIdps.map(
(idp) => (
<SelectItem
key={
idp.id
}
value={idp.id.toString()}
>
{
idp.text
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
{t("resourceUsersRolesSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceAuthMethodsDescriptions")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Password Protection */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
>
<Key size="14" />
<span>
{t("resourcePasswordProtection", {
status: authInfo.password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
>
<Binary size="14" />
<span>
{t("resourcePincodeProtection", {
status: authInfo.pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t("pincodeRemove")
: t("pincodeAdd")}
</Button>
</div>
{/* Header Authentication Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.headerAuth ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
>
<Bot size="14" />
<span>
{authInfo.headerAuth
? t("resourceHeaderAuthProtectionEnabled")
: t(
"resourceHeaderAuthProtectionDisabled"
)}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.headerAuth
? removeResourceHeaderAuth
: () => setIsSetHeaderAuthOpen(true)
}
loading={loadingRemoveResourceHeaderAuth}
>
{authInfo.headerAuth
? t("headerAuthRemove")
: t("headerAuthAdd")}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("otpEmailTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("otpEmailTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{!env.email.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={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.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={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t(
"otpEmailEnterDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
{t("otpEmailWhitelistSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}

View File

@@ -0,0 +1,525 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
import { UpdateResourceResponse } from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "@app/components/DomainsTable";
import { toASCII, toUnicode } from "punycode";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
const params = useParams();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
const router = useRouter();
const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false);
const { licenseStatus } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
const { user } = useUserContext();
const { env } = useEnvContext();
const orgId = params.orgId;
const api = createApiClient({ env });
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
niceId: z.string().min(1).max(255).optional(),
domainId: z.string().optional(),
proxyPort: z.int().min(1).max(65535).optional(),
// enableProxy: z.boolean().optional()
})
.refine(
(data) => {
// For non-HTTP resources, proxyPort should be defined
if (!resource.http) {
return data.proxyPort !== undefined;
}
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
},
{
message: !resource.http
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
enabled: resource.enabled,
name: resource.name,
niceId: resource.niceId,
subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined,
// enableProxy: resource.enableProxy || false
},
mode: "onChange"
});
useEffect(() => {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("domainErrorFetch"),
description: formatAxiosError(
e,
t("domainErrorFetchDescription")
)
});
});
if (res?.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains);
setFormKey((key) => key + 1);
}
};
const load = async () => {
await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []);
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
enabled: data.enabled,
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
domainId: data.domainId,
proxyPort: data.proxyPort,
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorUpdate"),
description: formatAxiosError(
e,
t("resourceErrorUpdateDescription")
)
});
});
if (res && res.status === 200) {
const updated = res.data.data;
updateResource({
enabled: data.enabled,
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain,
fullDomain: resource.fullDomain,
proxyPort: data.proxyPort,
// ...(!resource.http && {
// enableProxy: data.enableProxy
// })
});
toast({
title: t("resourceUpdated"),
description: t("resourceUpdatedDescription")
});
if (data.niceId && data.niceId !== resource?.niceId) {
router.replace(`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`);
} else {
router.refresh();
}
setSaveLoading(false);
}
setSaveLoading(false);
}
return (
!loadingPage && (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("enterIdentifier")}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!resource.http && (
<>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{/* {build == "oss" && (
<FormField
control={form.control}
name="enableProxy"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)} */}
</>
)}
{resource.http && (
<div className="space-y-2">
<Label>
{t("resourceDomain")}
</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{resourceFullDomain}
</span>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
{t(
"resourceEditDomain"
)}
</Button>
</div>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
onClick={() => {
console.log(form.getValues());
}}
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Edit Domain</CredenzaTitle>
<CredenzaDescription>
Select a domain for your resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
orgId={orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
const sanitizedSubdomain = selectedDomain.subdomain
? finalizeSubdomainSanitize(selectedDomain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
: selectedDomain.baseDomain;
setResourceFullDomain(`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`);
form.setValue("domainId", selectedDomain.domainId);
form.setValue("subdomain", sanitizedSubdomain);
setEditDomainOpen(false);
}
}}
>
Select Domain
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
)
);
}

View File

@@ -0,0 +1,121 @@
import ResourceProvider from "@app/providers/ResourceProvider";
import { internal } from "@app/lib/api";
import {
GetResourceAuthInfoResponse,
GetResourceResponse
} from "@server/routers/resource";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';
interface ResourceLayoutProps {
children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>;
}
export default async function ResourceLayout(props: ResourceLayoutProps) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
let authInfo = null;
let resource = null;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/org/${params.orgId}/resource/${params.niceId}`,
await authCookieHeader()
);
resource = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!resource) {
redirect(`/${params.orgId}/settings/resources`);
}
try {
const res = await internal.get<
AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${resource.resourceGuid}/auth`, await authCookieHeader());
authInfo = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!authInfo) {
redirect(`/${params.orgId}/settings/resources`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t('proxy'),
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: t('authentication'),
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
});
navItems.push({
title: t('rules'),
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
});
}
return (
<>
<SettingsSectionTitle
title={t('resourceSetting', {resourceName: resource?.name})}
description={t('resourceSettingDescription')}
/>
<OrgProvider org={org}>
<ResourceProvider
resource={resource}
authInfo={authInfo}
>
<div className="space-y-6">
<ResourceInfoBox />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</ResourceProvider>
</OrgProvider>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function ResourcePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,915 @@
"use client";
import { useEffect, useState, use } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { AxiosResponse } from "axios";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
import {
InfoSection,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { COUNTRIES } from "@server/db/countries";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
// Schema for rule validation
const addRuleSchema = z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
value: z.string(),
priority: z.coerce.number<number>().int().optional()
});
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
new?: boolean;
updated?: boolean;
};
export default function ResourceRules(props: {
params: Promise<{ resourceId: number }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const api = createApiClient(useEnvContext());
const [rules, setRules] = useState<LocalRule[]>([]);
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false);
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const isMaxmindAvailable = env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
const RuleAction = {
ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny'),
PASS: t('passToAuth')
} as const;
const RuleMatch = {
PATH: t('path'),
IP: "IP",
CIDR: t('ipAddressRange'),
COUNTRY: t('country')
} as const;
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
defaultValues: {
action: "ACCEPT",
match: "IP",
value: ""
}
});
useEffect(() => {
const fetchRules = async () => {
try {
const res = await api.get<
AxiosResponse<ListResourceRulesResponse>
>(`/resource/${resource.resourceId}/rules`);
if (res.status === 200) {
setRules(res.data.data.rules);
}
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorFetch'),
description: formatAxiosError(
err,
t('rulesErrorFetchDescription')
)
});
} finally {
setPageLoading(false);
}
};
fetchRules();
}, []);
async function addRule(data: z.infer<typeof addRuleSchema>) {
const isDuplicate = rules.some(
(rule) =>
rule.action === data.action &&
rule.match === data.match &&
rule.value === data.value
);
if (isDuplicate) {
toast({
variant: "destructive",
title: t('rulesErrorDuplicate'),
description: t('rulesErrorDuplicateDescription')
});
return;
}
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
});
setLoading(false);
return;
}
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
});
setLoading(false);
return;
}
if (data.match === "IP" && !isValidIP(data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
});
setLoading(false);
return;
}
if (data.match === "COUNTRY" && !COUNTRIES.some(c => c.code === data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidCountry'),
description: t('rulesErrorInvalidCountryDescription') || "Invalid country code."
});
setLoading(false);
return;
}
// find the highest priority and add one
let priority = data.priority;
if (priority === undefined) {
priority = rules.reduce(
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
0
);
priority++;
}
const newRule: LocalRule = {
...data,
ruleId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority,
enabled: true
};
setRules([...rules, newRule]);
addRuleForm.reset();
}
const removeRule = (ruleId: number) => {
setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]);
if (!rules.find((rule) => rule.ruleId === ruleId)?.new) {
setRulesToRemove([...rulesToRemove, ruleId]);
}
};
async function updateRule(ruleId: number, data: Partial<LocalRule>) {
setRules(
rules.map((rule) =>
rule.ruleId === ruleId
? { ...rule, ...data, updated: true }
: rule
)
);
}
function getValueHelpText(type: string) {
switch (type) {
case "CIDR":
return t('rulesMatchIpAddressRangeDescription');
case "IP":
return t('rulesMatchIpAddress');
case "PATH":
return t('rulesMatchUrl');
case "COUNTRY":
return t('rulesMatchCountry');
}
}
async function saveAllSettings() {
try {
setLoading(true);
// Save rules enabled state
const res = await api
.post(`/resource/${resource.resourceId}`, {
applyRules: rulesEnabled
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
)
});
throw err;
});
if (res && res.status === 200) {
updateResource({ applyRules: rulesEnabled });
}
// Save rules
for (const rule of rules) {
const data = {
action: rule.action,
match: rule.match,
value: rule.value,
priority: rule.priority,
enabled: rule.enabled
};
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
});
setLoading(false);
return;
}
if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
});
setLoading(false);
return;
}
if (rule.match === "IP" && !isValidIP(rule.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
});
setLoading(false);
return;
}
if (rule.priority === undefined) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidPriority'),
description: t('rulesErrorInvalidPriorityDescription')
});
setLoading(false);
return;
}
// make sure no duplicate priorities
const priorities = rules.map((r) => r.priority);
if (priorities.length !== new Set(priorities).size) {
toast({
variant: "destructive",
title: t('rulesErrorDuplicatePriority'),
description: t('rulesErrorDuplicatePriorityDescription')
});
setLoading(false);
return;
}
if (rule.new) {
const res = await api.put(
`/resource/${resource.resourceId}/rule`,
data
);
rule.ruleId = res.data.data.ruleId;
} else if (rule.updated) {
await api.post(
`/resource/${resource.resourceId}/rule/${rule.ruleId}`,
data
);
}
setRules([
...rules.map((r) => {
const res = {
...r,
new: false,
updated: false
};
return res;
})
]);
}
for (const ruleId of rulesToRemove) {
await api.delete(
`/resource/${resource.resourceId}/rule/${ruleId}`
);
setRules(rules.filter((r) => r.ruleId !== ruleId));
}
toast({
title: t('ruleUpdated'),
description: t('ruleUpdatedDescription')
});
setRulesToRemove([]);
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t('ruleErrorUpdate'),
description: formatAxiosError(
err,
t('ruleErrorUpdateDescription')
)
});
}
setLoading(false);
}
const columns: ColumnDef<LocalRule>[] = [
{
accessorKey: "priority",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('rulesPriority')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<Input
defaultValue={row.original.priority}
className="w-[75px]"
type="number"
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const parsed = z.int()
.optional()
.safeParse(e.target.value);
if (!parsed.data) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidIpAddress'), // correct priority or IP?
description: t('rulesErrorInvalidPriorityDescription')
});
setLoading(false);
return;
}
updateRule(row.original.ruleId, {
priority: parsed.data
});
}}
/>
)
},
{
accessorKey: "action",
header: () => (<span className="p-3">{t('rulesAction')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, { action: value })
}
>
<SelectTrigger className="min-w-[150px]">
<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",
header: () => (<span className="p-3">{t('rulesMatchType')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH" | "COUNTRY") =>
updateRule(row.original.ruleId, { match: value, value: value === "COUNTRY" ? "US" : row.original.value })
}
>
<SelectTrigger className="min-w-[125px]">
<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>
)}
</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"
className="min-w-[200px] justify-between"
>
{row.original.value
? COUNTRIES.find((country) => country.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-[200px] 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>
) : (
<Input
defaultValue={row.original.value}
className="min-w-[200px]"
onBlur={(e) =>
updateRule(row.original.ruleId, {
value: e.target.value
})
}
/>
)
)
},
{
accessorKey: "enabled",
header: () => (<span className="p-3">{t('enabled')}</span>),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val })
}
/>
)
},
{
id: "actions",
header: () => (<span className="p-3">{t('actions')}</span>),
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
{t('delete')}
</Button>
</div>
)
}
];
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
return <></>;
}
return (
<SettingsContainer>
{/* <Alert className="hidden md:block"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle> */}
{/* <AlertDescription className="mt-4"> */}
{/* <div className="space-y-1 mb-4"> */}
{/* <p> */}
{/* {t('rulesAboutDescription')} */}
{/* </p> */}
{/* </div> */}
{/* <InfoSections cols={2}> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* <Check className="text-green-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysAllow')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* <X className="text-red-500 w-4 h-4" /> */}
{/* {t('rulesActionAlwaysDeny')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* <InfoSection> */}
{/* <InfoSectionTitle> */}
{/* {t('rulesMatchCriteria')} */}
{/* </InfoSectionTitle> */}
{/* <ul className="text-sm text-muted-foreground space-y-1"> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddress')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaIpAddressRange')} */}
{/* </li> */}
{/* <li className="flex items-center gap-2"> */}
{/* {t('rulesMatchCriteriaUrl')} */}
{/* </li> */}
{/* </ul> */}
{/* </InfoSection> */}
{/* </InfoSections> */}
{/* </AlertDescription> */}
{/* </Alert> */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('rulesResource')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('rulesResourceDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<div className="flex items-center space-x-2">
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
</div>
<Form {...addRuleForm}>
<form
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
<SelectItem value="PASS">
{RuleAction.PASS}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<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>
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
{addRuleForm.watch("match") === "COUNTRY" ? (
<Popover open={openAddRuleCountrySelect} onOpenChange={setOpenAddRuleCountrySelect}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openAddRuleCountrySelect}
className="w-full justify-between"
>
{field.value
? COUNTRIES.find((country) => country.code === field.value)?.name +
" (" + field.value + ")"
: t('selectCountry')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t('searchCountries')} />
<CommandList>
<CommandEmpty>{t('noCountryFound')}</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
field.onChange(country.code);
setOpenAddRuleCountrySelect(false);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value === country.code
? "opacity-100"
: "opacity-0"
}`}
/>
{country.name} ({country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div>
</form>
</Form>
<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"
>
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('rulesOrder')} */}
{/* </TableCaption> */}
</Table>
</div>
</SettingsSectionBody>
</SettingsSection>
<div className="flex justify-end">
<Button
onClick={saveAllSettings}
loading={loading}
disabled={loading}
>
{t('saveAllSettings')}
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -604,7 +604,7 @@ export default function Page() {
}
if (isHttp) {
router.push(`/${orgId}/settings/resources/${niceId}`);
router.push(`/${orgId}/settings/resources/proxy/${niceId}`);
} else {
const tcpUdpData = tcpUdpForm.getValues();
// Only show config snippets if enableProxy is explicitly true
@@ -613,7 +613,7 @@ export default function Page() {
router.refresh();
// } else {
// // If enableProxy is false or undefined, go directly to resource page
// router.push(`/${orgId}/settings/resources/${id}`);
// router.push(`/${orgId}/settings/resources/proxy/${id}`);
// }
}
}
@@ -1898,7 +1898,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${niceId}/proxy`
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
)
}
>