From a0a369dc43ebd8c11723a3b265642b99ecfc5aef Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 5 Dec 2025 23:10:10 +0100 Subject: [PATCH 01/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20reverse?= =?UTF-8?q?=20proxy=20targets=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/proxy/[niceId]/proxy/page.tsx | 433 +++++++++--------- src/lib/queries.ts | 12 + 2 files changed, 217 insertions(+), 228 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index ba3499b5..450c43fa 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, use } from "react"; +import HealthCheckDialog from "@/components/HealthCheckDialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -11,11 +11,34 @@ import { SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { AxiosResponse } from "axios"; -import { ListTargetsResponse } from "@server/routers/target/listTargets"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { ContainersSelector } from "@app/components/ContainersSelector"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { + PathMatchDisplay, + PathMatchModal, + PathRewriteDisplay, + PathRewriteModal +} from "@app/components/PathMatchRenameModal"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { Form, FormControl, @@ -25,17 +48,11 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { CreateTargetResponse } from "@server/routers/target"; import { - ColumnDef, - getFilteredRowModel, - getSortedRowModel, - getPaginationRowModel, - getCoreRowModel, - useReactTable, - flexRender, - Row -} from "@tanstack/react-table"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Table, TableBody, @@ -44,89 +61,62 @@ import { 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 { GetSiteResponse, ListSitesResponse } from "@server/routers/site"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm -} from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { useRouter } from "next/navigation"; -import { isTargetValid } from "@server/lib/validators"; -import { tlsNameSchema } from "@server/lib/schemas"; -import { - CheckIcon, - ChevronsUpDown, - Settings, - Heart, - Check, - CircleCheck, - CircleX, - ArrowRight, - Plus, - MoveRight, - ArrowUp, - Info, - ArrowDown, - AlertTriangle -} from "lucide-react"; -import { ContainersSelector } from "@app/components/ContainersSelector"; -import { useTranslations } from "next-intl"; -import { build } from "@server/build"; -import HealthCheckDialog from "@/components/HealthCheckDialog"; -import { DockerManager, DockerState } from "@app/lib/docker"; -import { Container } from "@server/routers/site"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { cn } from "@app/lib/cn"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { parseHostTarget } from "@app/lib/parseHostTarget"; -import { HeadersInput } from "@app/components/HeadersInput"; -import { - PathMatchDisplay, - PathMatchModal, - PathRewriteDisplay, - PathRewriteModal -} from "@app/components/PathMatchRenameModal"; -import { Badge } from "@app/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { cn } from "@app/lib/cn"; +import { DockerManager, DockerState } from "@app/lib/docker"; +import { parseHostTarget } from "@app/lib/parseHostTarget"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { tlsNameSchema } from "@server/lib/schemas"; +import { isTargetValid } from "@server/lib/validators"; +import { CreateTargetResponse } from "@server/routers/target"; +import { ListTargetsResponse } from "@server/routers/target/listTargets"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { useQuery } from "@tanstack/react-query"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { AxiosResponse } from "axios"; +import { + AlertTriangle, + CheckIcon, + CircleCheck, + CircleX, + Info, + Plus, + Settings +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { use, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const addTargetSchema = z .object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), - siteId: z.int() - .positive({ - error: "You must select a site for a target." - }), + siteId: z.int().positive({ + error: "You must select a site for a target." + }), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) @@ -202,7 +192,7 @@ type LocalTarget = Omit< "protocol" >; -export default function ReverseProxyTargets(props: { +export default function ReverseProxyTargetsPage(props: { params: Promise<{ resourceId: number; orgId: string }>; }) { const params = use(props.params); @@ -213,9 +203,19 @@ export default function ReverseProxyTargets(props: { const api = createApiClient(useEnvContext()); + const { data: remoteTargets, isLoading: isLoadingTargets } = useQuery( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + const { data: sites = [], isLoading: isLoadingSites } = useQuery( + orgQueries.sites({ + orgId: params.orgId + }) + ); + const [targets, setTargets] = useState([]); const [targetsToRemove, setTargetsToRemove] = useState([]); - const [sites, setSites] = useState([]); const [dockerStates, setDockerStates] = useState>( new Map() ); @@ -259,8 +259,6 @@ export default function ReverseProxyTargets(props: { const [targetsLoading, setTargetsLoading] = useState(false); const [proxySettingsLoading, setProxySettingsLoading] = useState(false); - const [pageLoading, setPageLoading] = useState(true); - const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isAdvancedMode, setIsAdvancedMode] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("proxy-advanced-mode"); @@ -313,10 +311,6 @@ export default function ReverseProxyTargets(props: { ) }); - type ProxySettingsValues = z.infer; - type TlsSettingsValues = z.infer; - type TargetsSettingsValues = z.infer; - const tlsSettingsForm = useForm({ resolver: zodResolver(tlsSettingsSchema), defaultValues: { @@ -343,86 +337,19 @@ export default function ReverseProxyTargets(props: { }); useEffect(() => { - const fetchTargets = async () => { - try { - const res = await api.get>( - `/resource/${resource.resourceId}/targets` - ); - - if (res.status === 200) { - setTargets(res.data.data.targets); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorFetch"), - description: formatAxiosError( - err, - t("targetErrorFetchDescription") - ) - }); - } finally { - setPageLoading(false); + if (!isLoadingSites && sites) { + const newtSites = sites.filter((site) => site.type === "newt"); + for (const site of newtSites) { + initializeDockerForSite(site.siteId); } - }; - fetchTargets(); + } + }, [isLoadingSites, sites]); - const fetchSites = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${params.orgId}/sites`) - .catch((e) => { - toast({ - variant: "destructive", - title: t("sitesErrorFetch"), - description: formatAxiosError( - e, - t("sitesErrorFetchDescription") - ) - }); - }); - - if (res?.status === 200) { - setSites(res.data.data.sites); - - // Initialize Docker for newt sites - const newtSites = res.data.data.sites.filter( - (site) => site.type === "newt" - ); - for (const site of newtSites) { - initializeDockerForSite(site.siteId); - } - - // Sites loaded successfully - } - }; - fetchSites(); - - // const fetchSite = async () => { - // try { - // const res = await api.get>( - // `/site/${resource.siteId}` - // ); - // - // if (res.status === 200) { - // setSite(res.data.data); - // } - // } catch (err) { - // console.error(err); - // toast({ - // variant: "destructive", - // title: t("siteErrorFetch"), - // description: formatAxiosError( - // err, - // t("siteErrorFetchDescription") - // ) - // }); - // } - // }; - // fetchSite(); - }, []); + useEffect(() => { + if (!isLoadingTargets && remoteTargets) { + setTargets(remoteTargets); + } + }, [isLoadingTargets, remoteTargets]); // Save advanced mode preference to localStorage useEffect(() => { @@ -546,11 +473,11 @@ export default function ReverseProxyTargets(props: { prev.map((t) => t.targetId === target.targetId ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } : t ) ); @@ -607,16 +534,16 @@ export default function ReverseProxyTargets(props: { const newTarget: LocalTarget = { ...data, - path: isHttp ? (data.path || null) : null, - pathMatchType: isHttp ? (data.pathMatchType || null) : null, - rewritePath: isHttp ? (data.rewritePath || null) : null, - rewritePathType: isHttp ? (data.rewritePathType || null) : null, + path: isHttp ? data.path || null : null, + pathMatchType: isHttp ? data.pathMatchType || null : null, + rewritePath: isHttp ? data.rewritePath || null : null, + rewritePathType: isHttp ? data.rewritePathType || null : null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), new: true, resourceId: resource.resourceId, - priority: isHttp ? (data.priority || 100) : 100, + priority: isHttp ? data.priority || 100 : 100, hcEnabled: false, hcPath: null, hcMethod: null, @@ -631,7 +558,7 @@ export default function ReverseProxyTargets(props: { hcStatus: null, hcMode: null, hcUnhealthyInterval: null, - hcTlsServerName: null, + hcTlsServerName: null }; setTargets([...targets, newTarget]); @@ -653,11 +580,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } : target ) ); @@ -668,10 +595,10 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...config, - updated: true - } + ...target, + ...config, + updated: true + } : target ) ); @@ -733,7 +660,7 @@ export default function ReverseProxyTargets(props: { hcStatus: target.hcStatus || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName, + hcTlsServerName: target.hcTlsServerName }; // Only include path-related fields for HTTP resources @@ -877,7 +804,7 @@ export default function ReverseProxyTargets(props: { const healthCheckColumn: ColumnDef = { accessorKey: "healthCheck", - header: () => ({t("healthCheck")}), + header: () => {t("healthCheck")}, cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; const isEnabled = row.original.hcEnabled; @@ -949,7 +876,7 @@ export default function ReverseProxyTargets(props: { const matchPathColumn: ColumnDef = { accessorKey: "path", - header: () => ({t("matchPath")}), + header: () => {t("matchPath")}, cell: ({ row }) => { const hasPathMatch = !!( row.original.path || row.original.pathMatchType @@ -1011,7 +938,7 @@ export default function ReverseProxyTargets(props: { const addressColumn: ColumnDef = { accessorKey: "address", - header: () => ({t("address")}), + header: () => {t("address")}, cell: ({ row }) => { const selectedSite = sites.find( (site) => site.siteId === row.original.siteId @@ -1064,7 +991,7 @@ export default function ReverseProxyTargets(props: { className={cn( "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > @@ -1132,8 +1059,12 @@ export default function ReverseProxyTargets(props: { {row.original.method || "http"} - http - https + + http + + + https + h2c @@ -1225,7 +1156,7 @@ export default function ReverseProxyTargets(props: { const rewritePathColumn: ColumnDef = { accessorKey: "rewritePath", - header: () => ({t("rewritePath")}), + header: () => {t("rewritePath")}, cell: ({ row }) => { const hasRewritePath = !!( row.original.rewritePath || row.original.rewritePathType @@ -1295,7 +1226,7 @@ export default function ReverseProxyTargets(props: { const enabledColumn: ColumnDef = { accessorKey: "enabled", - header: () => ({t("enabled")}), + header: () => {t("enabled")}, cell: ({ row }) => (
= { id: "actions", - header: () => ({t("actions")}), + header: () => {t("actions")}, cell: ({ row }) => (
)} + +
+ +
- {resource.http && ( - - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- - {!env.flags.usePangolinDns && ( - ( - - - { - field.onChange( - val - ); - }} - /> - - - )} - /> - )} - ( - - - {t("targetTlsSni")} - - - - - - {t( - "targetTlsSniDescription" - )} - - - - )} - /> - - -
- - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - -
- - -
- - ( - - - {t("proxyCustomHeader")} - - - - - - {t( - "proxyCustomHeaderDescription" - )} - - - - )} - /> - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> - - -
-
-
- )} - - {!resource.http && resource.protocol == "tcp" && ( - - - - {t("proxyProtocol")} - - - {t("proxyProtocolDescription")} - - - - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - {proxySettingsForm.watch( - "proxyProtocol" - ) && ( - <> - ( - - - {t( - "proxyProtocolVersion" - )} - - - - - - {t( - "versionDescription" - )} - - - )} - /> - - - - - - {t("warning")}: - {" "} - {t("proxyProtocolWarning")} - - - - )} - - -
-
-
- )} - -
- -
- {selectedTargetForHealthCheck && ( )} - + ); } -function isIPInSubnet(subnet: string, ip: string): boolean { - const [subnetIP, maskBits] = subnet.split("/"); - const mask = parseInt(maskBits); +function ProxyResourceHttpForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); - if (mask < 0 || mask > 32) { - throw new Error("subnetMaskErrorInvalid"); - } + const tlsSettingsSchema = z.object({ + ssl: z.boolean(), + tlsServerName: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorTls") + } + ) + }); - // Convert IP addresses to binary numbers - const subnetNum = ipToNumber(subnetIP); - const ipNum = ipToNumber(ip); - - // Calculate subnet mask - const maskNum = mask === 32 ? -1 : ~((1 << (32 - mask)) - 1); - - // Check if the IP is in the subnet - return (subnetNum & maskNum) === (ipNum & maskNum); -} - -function ipToNumber(ip: string): number { - // Validate IP address format - const parts = ip.split("."); - - if (parts.length !== 4) { - throw new Error("ipAddressErrorInvalidFormat"); - } - - // Convert IP octets to 32-bit number - return parts.reduce((num, octet) => { - const oct = parseInt(octet); - if (isNaN(oct) || oct < 0 || oct > 255) { - throw new Error("ipAddressErrorInvalidOctet"); + const tlsSettingsForm = useForm({ + resolver: zodResolver(tlsSettingsSchema), + defaultValues: { + ssl: resource.ssl, + tlsServerName: resource.tlsServerName || "" } - return (num << 8) + oct; - }, 0); + }); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: resource.stickySession + } + }); + + const router = useRouter(); + const [, formAction, isSubmitting] = useActionState( + saveResourceHttpSettings, + null + ); + + async function saveResourceHttpSettings() { + const isValidTLS = await tlsSettingsForm.trigger(); + const isValidProxy = await proxySettingsForm.trigger(); + const targetSettingsForm = await targetsSettingsForm.trigger(); + if (!isValidTLS || !isValidProxy || !targetSettingsForm) return; + + try { + // Gather all settings + const stickySessionData = targetsSettingsForm.getValues(); + const tlsData = tlsSettingsForm.getValues(); + const proxyData = proxySettingsForm.getValues(); + + // Combine into one payload + const payload = { + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }; + + // Single API call to update all settings + await api.post(`/resource/${resource.resourceId}`, payload); + + // Update local resource context + updateResource({ + ...resource, + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + +
+ + {!env.flags.usePangolinDns && ( + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + )} + ( + + + {t("targetTlsSni")} + + + + + + {t("targetTlsSniDescription")} + + + + )} + /> + + +
+ + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+ + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t("proxyCustomHeaderDescription")} + + + + )} + /> + ( + + + {t("customHeaders")} + + + { + field.onChange(value); + }} + rows={4} + /> + + + {t("customHeadersDescription")} + + + + )} + /> + + +
+
+ +
+
+
+ ); +} + +function ProxyResourceProtocolForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const router = useRouter(); + + const [, formAction, isSubmitting] = useActionState( + saveProtocolSettings, + null + ); + + async function saveProtocolSettings() { + const isValid = proxySettingsForm.trigger(); + if (!isValid) return; + + try { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + + {t("proxyProtocolVersion")} + + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}:{" "} + {t("proxyProtocolWarning")} + + + + )} + + +
+
+ +
+
+
+ ); } diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index bb5501a6..84fa31ac 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -2,7 +2,7 @@ import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; -interface ResourceContextType { +export interface ResourceContextType { resource: GetResourceResponse; authInfo: GetResourceAuthInfoResponse; updateResource: (updatedResource: Partial) => void; From 72bc26f0f84c08a3ee98f56fe15e2df05e8531c0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 6 Dec 2025 01:14:15 +0100 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=92=AC=20update=20texts=20to=20be?= =?UTF-8?q?=20more=20specific?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +++ .../settings/resources/proxy/[niceId]/proxy/page.tsx | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3f20f7e6..0d5e80ea 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1308,6 +1308,9 @@ "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "documentation": "Documentation", "saveAllSettings": "Save All Settings", + "saveResourceTargets": "Save Targets", + "saveResourceHttp": "Save Additional fields", + "saveProxyProtocol": "Save Proxy protocol settings", "settingsUpdated": "Settings updated", "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index f9c8c8c2..8cee3da0 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1138,7 +1138,7 @@ function ProxyResourceTargetsForm({ loading={isSubmitting} type="submit" > - {t("save")} + {t("saveResourceTargets")} @@ -1486,7 +1486,7 @@ function ProxyResourceHttpForm({ loading={isSubmitting} type="submit" > - {t("save")} + {t("saveResourceHttp")} @@ -1687,7 +1687,7 @@ function ProxyResourceProtocolForm({ loading={isSubmitting} type="submit" > - {t("save")} + {t("saveProxyProtocol")} From ce6b609ca2ce1d0115bd4564742450230452e5a3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Dec 2025 21:15:26 +0100 Subject: [PATCH 04/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Update=20domain=20pi?= =?UTF-8?q?cker=20component=20to=20accept=20default=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DomainPicker.tsx | 252 ++++++++++++++------------------ src/lib/queries.ts | 12 ++ 2 files changed, 118 insertions(+), 146 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 2d17e39f..8fc6d583 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -1,8 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Command, @@ -13,45 +11,40 @@ import { CommandList, CommandSeparator } from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - AlertCircle, - CheckCircle2, - Building2, - Zap, - Check, - ChevronsUpDown, - ArrowUpDown -} from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { createApiClient, formatAxiosError } from "@/lib/api"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useEnvContext } from "@/hooks/useEnvContext"; import { toast } from "@/hooks/useToast"; -import { ListDomainsResponse } from "@server/routers/domain/listDomains"; -import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; -import { AxiosResponse } from "axios"; +import { createApiClient } from "@/lib/api"; import { cn } from "@/lib/cn"; -import { useTranslations } from "next-intl"; -import { build } from "@server/build"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { - sanitizeInputRaw, finalizeSubdomainSanitize, - validateByDomainType, - isValidSubdomainStructure + isValidSubdomainStructure, + sanitizeInputRaw, + validateByDomainType } from "@/lib/subdomain-utils"; +import { orgQueries } from "@app/lib/queries"; +import { build } from "@server/build"; +import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; +import { useQuery } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { + AlertCircle, + Building2, + Check, + CheckCircle2, + ChevronsUpDown, + Zap +} from "lucide-react"; +import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; - -type OrganizationDomain = { - domainId: string; - baseDomain: string; - verified: boolean; - type: "ns" | "cname" | "wildcard"; -}; +import { useCallback, useEffect, useMemo, useState } from "react"; type AvailableOption = { domainNamespaceId: string; @@ -69,7 +62,7 @@ type DomainOption = { domainNamespaceId?: string; }; -interface DomainPicker2Props { +interface DomainPickerProps { orgId: string; onDomainChange?: (domainInfo: { domainId: string; @@ -81,116 +74,115 @@ interface DomainPicker2Props { }) => void; cols?: number; hideFreeDomain?: boolean; + defaultSubdomain?: string; + defaultBaseDomain?: string; } -export default function DomainPicker2({ +export default function DomainPicker({ orgId, onDomainChange, cols = 2, - hideFreeDomain = false -}: DomainPicker2Props) { + hideFreeDomain = false, + defaultSubdomain, + defaultBaseDomain +}: DomainPickerProps) { const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); + const { data = [], isLoading: loadingDomains } = useQuery( + orgQueries.domains({ orgId }) + ); + + console.log({ defaultSubdomain, defaultBaseDomain }); + if (!env.flags.usePangolinDns) { hideFreeDomain = true; } - const [subdomainInput, setSubdomainInput] = useState(""); + const [subdomainInput, setSubdomainInput] = useState( + defaultSubdomain ?? "" + ); const [selectedBaseDomain, setSelectedBaseDomain] = useState(null); const [availableOptions, setAvailableOptions] = useState( [] ); - const [organizationDomains, setOrganizationDomains] = useState< - OrganizationDomain[] - >([]); - const [loadingDomains, setLoadingDomains] = useState(false); + + // memoized to prevent reruning the effect that selects the initial domain indefinitely + // removing this will break and cause an infinite rerender + const organizationDomains = useMemo(() => { + return data + .filter( + (domain) => + domain.type === "ns" || + domain.type === "cname" || + domain.type === "wildcard" + ) + .map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + type: domain.type as "ns" | "cname" | "wildcard" + })); + }, [data]); + const [open, setOpen] = useState(false); // Provided domain search states const [userInput, setUserInput] = useState(""); const [isChecking, setIsChecking] = useState(false); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [providedDomainsShown, setProvidedDomainsShown] = useState(3); const [selectedProvidedDomain, setSelectedProvidedDomain] = useState(null); useEffect(() => { - const loadOrganizationDomains = async () => { - setLoadingDomains(true); - try { - const response = await api.get< - AxiosResponse - >(`/org/${orgId}/domains`); - if (response.status === 200) { - const domains = response.data.data.domains - .filter( - (domain) => - domain.type === "ns" || - domain.type === "cname" || - domain.type === "wildcard" - ) - .map((domain) => ({ - ...domain, - baseDomain: toUnicode(domain.baseDomain), - type: domain.type as "ns" | "cname" | "wildcard" - })); - setOrganizationDomains(domains); + if (!loadingDomains) { + if (organizationDomains.length > 0) { + // Select the first organization domain or the one provided from props + const firstOrgDomain = + organizationDomains.find( + (domain) => domain.baseDomain === defaultBaseDomain + ) ?? organizationDomains[0]; + const domainOption: DomainOption = { + id: `org-${firstOrgDomain.domainId}`, + domain: firstOrgDomain.baseDomain, + type: "organization", + verified: firstOrgDomain.verified, + domainType: firstOrgDomain.type, + domainId: firstOrgDomain.domainId + }; + setSelectedBaseDomain(domainOption); - // Auto-select first available domain - if (domains.length > 0) { - // Select the first organization domain - const firstOrgDomain = domains[0]; - const domainOption: DomainOption = { - id: `org-${firstOrgDomain.domainId}`, - domain: firstOrgDomain.baseDomain, - type: "organization", - verified: firstOrgDomain.verified, - domainType: firstOrgDomain.type, - domainId: firstOrgDomain.domainId - }; - setSelectedBaseDomain(domainOption); - - onDomainChange?.({ - domainId: firstOrgDomain.domainId, - type: "organization", - subdomain: undefined, - fullDomain: firstOrgDomain.baseDomain, - baseDomain: firstOrgDomain.baseDomain - }); - } else if ( - (build === "saas" || build === "enterprise") && - !hideFreeDomain - ) { - // If no organization domains, select the provided domain option - const domainOptionText = - build === "enterprise" - ? t("domainPickerProvidedDomain") - : t("domainPickerFreeProvidedDomain"); - const freeDomainOption: DomainOption = { - id: "provided-search", - domain: domainOptionText, - type: "provided-search" - }; - setSelectedBaseDomain(freeDomainOption); - } - } - } catch (error) { - console.error("Failed to load organization domains:", error); - toast({ - variant: "destructive", - title: t("domainPickerError"), - description: t("domainPickerErrorLoadDomains") + onDomainChange?.({ + domainId: firstOrgDomain.domainId, + type: "organization", + subdomain: undefined, + fullDomain: firstOrgDomain.baseDomain, + baseDomain: firstOrgDomain.baseDomain }); - } finally { - setLoadingDomains(false); + } else if ( + (build === "saas" || build === "enterprise") && + !hideFreeDomain + ) { + // If no organization domains, select the provided domain option + const domainOptionText = + build === "enterprise" + ? t("domainPickerProvidedDomain") + : t("domainPickerFreeProvidedDomain"); + const freeDomainOption: DomainOption = { + id: "provided-search", + domain: domainOptionText, + type: "provided-search" + }; + setSelectedBaseDomain(freeDomainOption); } - }; - - loadOrganizationDomains(); - }, [orgId, api, hideFreeDomain]); + } + }, [ + hideFreeDomain, + loadingDomains, + organizationDomains, + defaultBaseDomain + ]); const checkAvailability = useCallback( async (input: string) => { @@ -256,37 +248,6 @@ export default function DomainPicker2({ } }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); - const generateDropdownOptions = (): DomainOption[] => { - const options: DomainOption[] = []; - - organizationDomains.forEach((orgDomain) => { - options.push({ - id: `org-${orgDomain.domainId}`, - domain: orgDomain.baseDomain, - type: "organization", - verified: orgDomain.verified, - domainType: orgDomain.type, - domainId: orgDomain.domainId - }); - }); - - if ((build === "saas" || build === "enterprise") && !hideFreeDomain) { - const domainOptionText = - build === "enterprise" - ? t("domainPickerProvidedDomain") - : t("domainPickerFreeProvidedDomain"); - options.push({ - id: "provided-search", - domain: domainOptionText, - type: "provided-search" - }); - } - - return options; - }; - - const dropdownOptions = generateDropdownOptions(); - const finalizeSubdomain = (sub: string, base: DomainOption): string => { const sanitized = finalizeSubdomainSanitize(sub); @@ -440,8 +401,7 @@ export default function DomainPicker2({ selectedBaseDomain?.type === "provided-search"; const sortedAvailableOptions = [...availableOptions].sort((a, b) => { - const comparison = a.fullDomain.localeCompare(b.fullDomain); - return sortOrder === "asc" ? comparison : -comparison; + return a.fullDomain.localeCompare(b.fullDomain); }); const displayedProvidedOptions = sortedAvailableOptions.slice( @@ -518,16 +478,16 @@ export default function DomainPicker2({ className="w-full justify-between" > {selectedBaseDomain ? ( -
+
{selectedBaseDomain.type === "organization" ? null : ( - + )} {selectedBaseDomain.domain} {selectedBaseDomain.verified && ( - + )}
) : ( diff --git a/src/lib/queries.ts b/src/lib/queries.ts index de3bf023..9e43adf7 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -17,6 +17,7 @@ import { durationToMs } from "./durationToMs"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { ListResourceNamesResponse } from "@server/routers/resource"; import type { ListTargetsResponse } from "@server/routers/target"; +import type { ListDomainsResponse } from "@server/routers/domain"; export type ProductUpdate = { link: string | null; @@ -140,6 +141,17 @@ export const orgQueries = { >(`/org/${orgId}/sites`, { signal }); return res.data.data.sites; } + }), + + domains: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "DOMAINS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/domains`, { signal }); + return res.data.data.domains; + } }) }; From 4e842a660a37874cc9770ef1ac157c031b0f6070 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Dec 2025 21:15:42 +0100 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20refactor=20proxy=20?= =?UTF-8?q?resource=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/proxy/[niceId]/general/page.tsx | 668 ++++++++---------- .../resources/proxy/[niceId]/layout.tsx | 3 +- 2 files changed, 286 insertions(+), 385 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index fa9a6976..6712f201 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -1,8 +1,5 @@ "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, @@ -15,31 +12,10 @@ import { } 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 { formatAxiosError } from "@app/lib/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + import { Credenza, CredenzaBody, @@ -51,26 +27,37 @@ import { CredenzaTitle } from "@app/components/Credenza"; import DomainPicker from "@app/components/DomainPicker"; -import { Globe } from "lucide-react"; -import { build } from "@server/build"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Label } from "@app/components/ui/label"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "@app/components/DomainsTable"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { AxiosResponse } from "axios"; +import { Globe } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; 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(); @@ -78,18 +65,30 @@ export default function GeneralForm() { const api = createApiClient({ env }); - const [sites, setSites] = useState([]); - 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 || "")}` ); + + console.log({ resource }); + + const [defaultSubdomain, defaultBaseDomain] = useMemo(() => { + const resourceUrl = new URL(resourceFullDomain); + const domain = resourceUrl.hostname; + + const allDomainParts = domain.split("."); + let sub = undefined; + let base = domain; + + if (allDomainParts.length >= 3) { + // 3 parts: [subdomain, domain, tld] + const [first, ...rest] = allDomainParts; + sub = first; + base = rest.join("."); + } + + return [sub, base]; + }, [resourceFullDomain]); + const [selectedDomain, setSelectedDomain] = useState<{ domainId: string; subdomain?: string; @@ -105,7 +104,6 @@ export default function GeneralForm() { 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) => { @@ -124,8 +122,6 @@ export default function GeneralForm() { } ); - type GeneralFormValues = z.infer; - const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { @@ -135,58 +131,17 @@ export default function GeneralForm() { 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>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; + const [, formAction, saveLoading] = useActionState(onSubmit, null); - const fetchDomains = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${orgId}/domains/`) - .catch((e) => { - toast({ - variant: "destructive", - title: t("domainErrorFetch"), - description: formatAxiosError( - e, - t("domainErrorFetchDescription") - ) - }); - }); + async function onSubmit() { + const isValid = await form.trigger(); + if (!isValid) return; - 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 data = form.getValues(); const res = await api .post>( @@ -200,9 +155,6 @@ export default function GeneralForm() { : undefined, domainId: data.domainId, proxyPort: data.proxyPort - // ...(!resource.http && { - // enableProxy: data.enableProxy - // }) } ) .catch((e) => { @@ -226,9 +178,6 @@ export default function GeneralForm() { subdomain: data.subdomain, fullDomain: resource.fullDomain, proxyPort: data.proxyPort - // ...(!resource.http && { - // enableProxy: data.enableProxy - // }) }); toast({ @@ -240,306 +189,257 @@ export default function GeneralForm() { router.replace( `/${updated.orgId}/settings/resources/proxy/${data.niceId}/general` ); - } else { - router.refresh(); } - setSaveLoading(false); + router.refresh(); } - - setSaveLoading(false); } return ( - !loadingPage && ( - <> - - - - - {t("resourceGeneral")} - - - {t("resourceGeneralDescription")} - - + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + - - -
- - ( - -
- - + + + + ( + +
+ + + form.setValue( + "enabled", val - ) => - form.setValue( - "enabled", - val + ) + } + /> + +
+ +
+ )} + /> + + ( + + + {t("name")} + + + + + + + )} + /> + + ( + + + {t("identifier")} + + + + + + + )} + /> + + {!resource.http && ( + <> + ( + + + {t( + "resourcePortNumber" + )} + + + + field.onChange( + e.target + .value + ? parseInt( + e + .target + .value + ) + : undefined ) } /> -
- -
- )} - /> - - ( - - - {t("name")} - - - - - - - )} - /> - - ( - - - {t("identifier")} - - - + + {t( + "resourcePortNumberDescription" )} - className="flex-1" - /> - - - - )} - /> + + + )} + /> + + )} - {!resource.http && ( - <> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( - e - .target - .value - ? parseInt( - e - .target - .value - ) - : undefined - ) - } - /> - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - - {/* {build == "oss" && ( - ( - - - - -
- - {t( - "resourceEnableProxy" - )} - - - {t( - "resourceEnableProxyDescription" - )} - -
-
- )} - /> - )} */} - - )} - - {resource.http && ( -
- -
- - - {resourceFullDomain} - - -
+ {resource.http && ( +
+ +
+ + + {resourceFullDomain} + +
- )} - - - - +
+ )} + + + + - - - - - + + + + + - setEditDomainOpen(setOpen)} - > - - - Edit Domain - - Select a domain for your resource - - - - { - const selected = { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain - }; - setSelectedDomain(selected); - }} - /> - - - - - - + + - - - - - ) + setEditDomainOpen(false); + } + }} + > + Select Domain + + + + + ); } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index c453b577..f410b4c8 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -13,9 +13,10 @@ 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"; +export const dynamic = "force-dynamic"; + interface ResourceLayoutProps { children: React.ReactNode; params: Promise<{ niceId: string; orgId: string }>; From 124ba208de648bfae22057b3556b668be0d0e16f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 12 Dec 2025 21:40:49 +0100 Subject: [PATCH 06/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20react=20querty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/[niceId]/authentication/page.tsx | 318 +++++++++--------- src/lib/queries.ts | 28 +- 2 files changed, 177 insertions(+), 169 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 27f4fd73..34087076 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,21 +1,22 @@ "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 SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; +import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import { - GetResourceWhitelistResponse, - ListResourceRolesResponse, - ListResourceUsersResponse -} from "@server/routers/resource"; + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +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 { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Form, FormControl, @@ -25,32 +26,7 @@ import { 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, @@ -58,10 +34,33 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { Separator } from "@app/components/ui/separator"; -import { build } from "@server/build"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { useResourceContext } from "@app/hooks/useResourceContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { TierId } from "@server/lib/billing/tiers"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { + GetResourceWhitelistResponse, + ListResourceRolesResponse, + ListResourceUsersResponse +} from "@server/routers/resource"; +import { ListRolesResponse } from "@server/routers/role"; +import { ListUsersResponse } from "@server/routers/user"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import SetResourcePasswordForm from "components/SetResourcePasswordForm"; +import type { text } from "express"; +import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -100,14 +99,83 @@ export default function ResourceAuthenticationPage() { const subscription = useSubscriptionStatusContext(); - const [pageLoading, setPageLoading] = useState(true); + const queryClient = useQueryClient(); + const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = + useQuery( + resourceQueries.resourceRoles({ + resourceId: resource.resourceId + }) + ); + const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } = + useQuery( + resourceQueries.resourceUsers({ + resourceId: resource.resourceId + }) + ); - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ + orgId: org.org.orgId + }) ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ + orgId: org.org.orgId + }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId + }) + ); + + const pageLoading = + isLoadingOrgRoles || + isLoadingOrgUsers || + isLoadingResourceRoles || + isLoadingResourceUsers || + isLoadingWhiteList || + isLoadingOrgIdps; + + const allRoles = useMemo(() => { + return orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + }, [orgRoles]); + + const allUsers = useMemo(() => { + return orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + }, [orgUsers]); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (subscription?.subscribed) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + return []; + }, [orgIdps]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -131,10 +199,10 @@ export default function ResourceAuthenticationPage() { const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null ); - const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]); const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); - const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); + const [loadingSaveWhitelist, startSaveWhitelistTransition] = + useTransition(); const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = useState(false); @@ -159,126 +227,35 @@ export default function ResourceAuthenticationPage() { defaultValues: { emails: [] } }); + const hasInitializedRef = useRef(false); + useEffect(() => { - const fetchData = async () => { - try { - const [ - rolesResponse, - resourceRolesResponse, - usersResponse, - resourceUsersResponse, - whitelist, - idpsResponse - ] = await Promise.all([ - api.get>( - `/org/${org?.org.orgId}/roles` - ), - api.get>( - `/resource/${resource.resourceId}/roles` - ), - api.get>( - `/org/${org?.org.orgId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/whitelist` - ), - api.get< - AxiosResponse<{ - idps: { idpId: number; name: string }[]; - }> - >(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp") - ]); + if (!pageLoading || hasInitializedRef.current) return; - 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(); - }, []); + usersRolesForm.setValue("roles", allRoles); + usersRolesForm.setValue("users", allUsers); + whitelistForm.setValue( + "emails", + whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + if (autoLoginEnabled && !selectedIdpId && orgIdps.length > 0) { + setSelectedIdpId(orgIdps[0].idpId); + } + hasInitializedRef.current = true; + }, [ + pageLoading, + allRoles, + allUsers, + whitelist, + autoLoginEnabled, + selectedIdpId, + orgIdps + ]); async function saveWhitelist() { - setLoadingSaveWhitelist(true); try { await api.post(`/resource/${resource.resourceId}`, { emailWhitelistEnabled: whitelistEnabled @@ -299,6 +276,11 @@ export default function ResourceAuthenticationPage() { description: t("resourceWhitelistSaveDescription") }); router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) + ); } catch (e) { console.error(e); toast({ @@ -309,8 +291,6 @@ export default function ResourceAuthenticationPage() { t("resourceErrorWhitelistSaveDescription") ) }); - } finally { - setLoadingSaveWhitelist(false); } } @@ -984,7 +964,9 @@ export default function ResourceAuthenticationPage() { - - + ); } + +type OneTimePasswordFormSectionProps = Pick< + ResourceContextType, + "resource" | "updateResource" +>; + +function OneTimePasswordFormSection({ + resource, + updateResource +}: OneTimePasswordFormSectionProps) { + const { env } = useEnvContext(); + const [whitelistEnabled, setWhitelistEnabled] = useState( + resource.emailWhitelistEnabled + ); + const queryClient = useQueryClient(); + + const [loadingSaveWhitelist, startTransition] = useTransition(); + const whitelistForm = useForm({ + resolver: zodResolver(whitelistSchema), + defaultValues: { emails: [] } + }); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + async function saveWhitelist() { + 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(); + await queryClient.invalidateQueries( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) + ); + } catch (e) { + console.error(e); + toast({ + variant: "destructive", + title: t("resourceErrorWhitelistSave"), + description: formatAxiosError( + e, + t("resourceErrorWhitelistSaveDescription") + ) + }); + } + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + + + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + 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} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + + + )} +
+
+ + + +
+ ); +} From 9cee3d9c798bda790d498e981a542acf55459913 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 12 Dec 2025 23:35:24 +0100 Subject: [PATCH 10/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/[niceId]/authentication/page.tsx | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 15c8e197..8de6aa13 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -44,18 +44,9 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; -import { - GetResourceWhitelistResponse, - ListResourceRolesResponse, - ListResourceUsersResponse -} from "@server/routers/resource"; -import { ListRolesResponse } from "@server/routers/role"; -import { ListUsersResponse } from "@server/routers/user"; import { UserType } from "@server/types/UserTypes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AxiosResponse } from "axios"; import SetResourcePasswordForm from "components/SetResourcePasswordForm"; -import type { text } from "express"; import { Binary, Bot, InfoIcon, Key } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -191,15 +182,7 @@ export default function ResourceAuthenticationPage() { 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 @@ -274,45 +257,6 @@ export default function ResourceAuthenticationPage() { orgIdps ]); - async function saveWhitelist() { - 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(); - await queryClient.invalidateQueries( - resourceQueries.resourceWhitelist({ - resourceId: resource.resourceId - }) - ); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorWhitelistSave"), - description: formatAxiosError( - e, - t("resourceErrorWhitelistSaveDescription") - ) - }); - } - } - const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState( onSubmitUsersRoles, null From 9125a7bccbd80c7d0d3f8bc3376a1903960ad234 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 15 Dec 2025 23:18:28 +0100 Subject: [PATCH 11/20] =?UTF-8?q?=F0=9F=9A=A7=20org=20settings=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 +- src/app/[orgId]/settings/general/page.tsx | 500 +++++++++++----------- 2 files changed, 261 insertions(+), 243 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3601d6bd..ee1a7c20 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1853,6 +1853,8 @@ "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", "securitySettings": "Security Settings", + "dangerSection": "Danger section", + "dangerSectionDescription": "Delete organization alongside all its sites, clients, resources, etc...", "securitySettingsDescription": "Configure security policies for the organization", "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", @@ -2063,7 +2065,7 @@ "request": "Request", "requests": "Requests", "logs": "Logs", - "logsSettingsDescription": "Monitor logs collected from this orginization", + "logsSettingsDescription": "Monitor logs collected from this organization", "searchLogs": "Search logs...", "action": "Action", "actor": "Actor", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 97dd4a03..576589a1 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -368,9 +368,9 @@ export default function GeneralPage() { /> - - +
+ {t("logRetention")} @@ -587,266 +587,258 @@ export default function GeneralPage() { )} -
- {build !== "oss" && ( - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = - isSecurityFeatureDisabled(); + {build !== "oss" && ( + <> +
+ + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + + + { + const isDisabled = + isSecurityFeatureDisabled(); - return ( - -
+ return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t( + "maxSessionLength" + )} + - { if ( !isDisabled ) { + const numValue = + value === + "null" + ? null + : parseInt( + value, + 10 + ); form.setValue( - "requireTwoFactor", - val + "maxSessionLengthHours", + numValue ); } }} - /> + disabled={ + isDisabled + } + > + + + + + {SESSION_LENGTH_OPTIONS.map( + ( + option + ) => ( + + {t( + option.labelKey + )} + + ) + )} + + -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t( - "passwordExpiryDays" - )} - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> -
-
-
- )} + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t( + "passwordExpiryDays" + )} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + + + )} + {build === "saas" && }
- {build !== "saas" && ( - - )}
+ + {build !== "saas" && ( + + + + {t("dangerSection")} + + + {t("dangerSectionDescription")} + + +
+ +
+
+ )} ); } From 872bb557c21673d7321f0a85e7a49e03139be0ad Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 15 Dec 2025 23:36:13 +0100 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=92=84=20put=20save=20org=20setting?= =?UTF-8?q?s=20button=20into=20the=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/general/page.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 576589a1..24176dbb 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -832,23 +832,23 @@ export default function GeneralPage() { )} + +
+ +
{build === "saas" && } -
- -
- {build !== "saas" && ( From 23a768878918453f33d8730eefc412fc040da7da Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 15 Dec 2025 23:51:06 +0100 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=92=84=20more=20margin=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 09e5d918..ada2defe 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1131,7 +1131,7 @@ function ProxyResourceTargetsForm({ )} -
+ +
+
+ + ); +} + +function LogRetentionSectionForm() { + const { org } = useOrgContext(); + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: org?.org.name + }, + mode: "onChange" + }); + const t = useTranslations(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + const { isPaidUser } = usePaidStatus(); + + return ( + + + {t("logRetention")} + + {t("logRetentionDescription")} + + + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {build != "oss" && ( + <> + + + { + const isDisabled = + (build == "saas" && + !subscription?.subscribed) || + (build == "enterprise" && + !isUnlocked()); + + return ( + + + {t("logRetentionAccessLabel")} + + + + + + + ); + }} + /> + { + const isDisabled = + (build == "saas" && + !subscription?.subscribed) || + (build == "enterprise" && + !isUnlocked()); + + return ( + + + {t("logRetentionActionLabel")} + + + + + + + ); + }} + /> + + )} + + + + ); +} + +function SectionForm() { + const { org } = useOrgContext(); + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: org?.org.name + }, + mode: "onChange" + }); + const t = useTranslations(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + const { isPaidUser } = usePaidStatus(); + + return ( + + {build !== "oss" && ( + <> +
+ + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + + + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + +
+ + { + if (!isDisabled) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> +
+
+ + )} +
+ ); +} diff --git a/src/hooks/usePaidStatus.ts b/src/hooks/usePaidStatus.ts new file mode 100644 index 00000000..d8173e6e --- /dev/null +++ b/src/hooks/usePaidStatus.ts @@ -0,0 +1,21 @@ +import { build } from "@server/build"; +import { useLicenseStatusContext } from "./useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "./useSubscriptionStatusContext"; + +export function usePaidStatus() { + const { isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + // Check if features are disabled due to licensing/subscription + const hasEnterpriseLicense = build === "enterprise" && isUnlocked(); + const hasSaasSubscription = + build === "saas" && + subscription?.isSubscribed() && + subscription.isActive(); + + return { + hasEnterpriseLicense, + hasSaasSubscription, + isPaidUser: hasEnterpriseLicense || hasSaasSubscription + }; +} From 7ae80d2cadae4b7e024adec0b067cd818ebcb0f2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 18 Dec 2025 00:20:19 +0100 Subject: [PATCH 18/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20apply=20domain=20pic?= =?UTF-8?q?ker=20from=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DomainPicker.tsx | 242 ++++++++++++++++++-------------- 1 file changed, 137 insertions(+), 105 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 8fc6d583..071bc1af 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -64,18 +64,21 @@ type DomainOption = { interface DomainPickerProps { orgId: string; - onDomainChange?: (domainInfo: { - domainId: string; - domainNamespaceId?: string; - type: "organization" | "provided"; - subdomain?: string; - fullDomain: string; - baseDomain: string; - }) => void; + onDomainChange?: ( + domainInfo: { + domainId: string; + domainNamespaceId?: string; + type: "organization" | "provided"; + subdomain?: string; + fullDomain: string; + baseDomain: string; + } | null + ) => void; cols?: number; hideFreeDomain?: boolean; - defaultSubdomain?: string; - defaultBaseDomain?: string; + defaultFullDomain?: string | null; + defaultSubdomain?: string | null; + defaultDomainId?: string | null; } export default function DomainPicker({ @@ -84,7 +87,8 @@ export default function DomainPicker({ cols = 2, hideFreeDomain = false, defaultSubdomain, - defaultBaseDomain + defaultFullDomain, + defaultDomainId }: DomainPickerProps) { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -94,8 +98,6 @@ export default function DomainPicker({ orgQueries.domains({ orgId }) ); - console.log({ defaultSubdomain, defaultBaseDomain }); - if (!env.flags.usePangolinDns) { hideFreeDomain = true; } @@ -103,6 +105,7 @@ export default function DomainPicker({ const [subdomainInput, setSubdomainInput] = useState( defaultSubdomain ?? "" ); + const [selectedBaseDomain, setSelectedBaseDomain] = useState(null); const [availableOptions, setAvailableOptions] = useState( @@ -129,7 +132,7 @@ export default function DomainPicker({ const [open, setOpen] = useState(false); // Provided domain search states - const [userInput, setUserInput] = useState(""); + const [userInput, setUserInput] = useState(defaultSubdomain ?? ""); const [isChecking, setIsChecking] = useState(false); const [providedDomainsShown, setProvidedDomainsShown] = useState(3); const [selectedProvidedDomain, setSelectedProvidedDomain] = @@ -137,51 +140,67 @@ export default function DomainPicker({ useEffect(() => { if (!loadingDomains) { + let domainOptionToSelect: DomainOption | null = null; if (organizationDomains.length > 0) { // Select the first organization domain or the one provided from props - const firstOrgDomain = - organizationDomains.find( - (domain) => domain.baseDomain === defaultBaseDomain - ) ?? organizationDomains[0]; - const domainOption: DomainOption = { - id: `org-${firstOrgDomain.domainId}`, - domain: firstOrgDomain.baseDomain, - type: "organization", - verified: firstOrgDomain.verified, - domainType: firstOrgDomain.type, - domainId: firstOrgDomain.domainId - }; - setSelectedBaseDomain(domainOption); + let firstOrExistingDomain = organizationDomains.find( + (domain) => domain.domainId === defaultDomainId + ); + // if no default Domain + if (!defaultDomainId) { + firstOrExistingDomain = organizationDomains[0]; + } - onDomainChange?.({ - domainId: firstOrgDomain.domainId, - type: "organization", - subdomain: undefined, - fullDomain: firstOrgDomain.baseDomain, - baseDomain: firstOrgDomain.baseDomain - }); - } else if ( - (build === "saas" || build === "enterprise") && - !hideFreeDomain + if (firstOrExistingDomain) { + domainOptionToSelect = { + id: `org-${firstOrExistingDomain.domainId}`, + domain: firstOrExistingDomain.baseDomain, + type: "organization", + verified: firstOrExistingDomain.verified, + domainType: firstOrExistingDomain.type, + domainId: firstOrExistingDomain.domainId + }; + + onDomainChange?.({ + domainId: firstOrExistingDomain.domainId, + type: "organization", + subdomain: + firstOrExistingDomain.type !== "cname" + ? defaultSubdomain || undefined + : undefined, + fullDomain: firstOrExistingDomain.baseDomain, + baseDomain: firstOrExistingDomain.baseDomain + }); + } + } + + if ( + !domainOptionToSelect && + build !== "oss" && + !hideFreeDomain && + defaultDomainId !== undefined ) { // If no organization domains, select the provided domain option const domainOptionText = build === "enterprise" ? t("domainPickerProvidedDomain") : t("domainPickerFreeProvidedDomain"); - const freeDomainOption: DomainOption = { + // free domain option + domainOptionToSelect = { id: "provided-search", domain: domainOptionText, type: "provided-search" }; - setSelectedBaseDomain(freeDomainOption); } + + setSelectedBaseDomain(domainOptionToSelect); } }, [ - hideFreeDomain, loadingDomains, organizationDomains, - defaultBaseDomain + defaultSubdomain, + hideFreeDomain, + defaultDomainId ]); const checkAvailability = useCallback( @@ -344,6 +363,9 @@ export default function DomainPicker({ setSelectedProvidedDomain(null); } + console.log({ + setSelectedBaseDomain: option + }); setSelectedBaseDomain(option); setOpen(false); @@ -354,15 +376,21 @@ export default function DomainPicker({ const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; - onDomainChange?.({ - domainId: option.domainId || "", - domainNamespaceId: option.domainNamespaceId, - type: - option.type === "provided-search" ? "provided" : "organization", - subdomain: sub || undefined, - fullDomain, - baseDomain: option.domain - }); + if (option.type === "provided-search") { + onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` + } else { + onDomainChange?.({ + domainId: option.domainId || "", + domainNamespaceId: option.domainNamespaceId, + type: "organization", + subdomain: + option.domainType !== "cname" + ? sub || undefined + : undefined, + fullDomain, + baseDomain: option.domain + }); + } }; const handleProvidedDomainSelect = (option: AvailableOption) => { @@ -408,6 +436,15 @@ export default function DomainPicker({ 0, providedDomainsShown ); + console.log({ + displayedProvidedOptions + }); + + const selectedDomainNamespaceId = + selectedProvidedDomain?.domainNamespaceId ?? + displayedProvidedOptions.find( + (opt) => opt.fullDomain === defaultFullDomain + )?.domainNamespaceId; const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; @@ -455,16 +492,6 @@ export default function DomainPicker({ {t("domainPickerInvalidSubdomainStructure")}

)} - {showSubdomainInput && !subdomainInput && ( -

- {t("domainPickerEnterSubdomainOrLeaveBlank")} -

- )} - {showProvidedDomainSearch && !userInput && ( -

- {t("domainPickerEnterSubdomainToSearch")} -

- )}
@@ -693,10 +720,8 @@ export default function DomainPicker({ {!isChecking && sortedAvailableOptions.length > 0 && (
{ const option = displayedProvidedOptions.find( @@ -707,49 +732,56 @@ export default function DomainPicker({ handleProvidedDomainSelect(option); } }} - className={`grid gap-2 grid-cols-1 sm:grid-cols-${cols}`} + style={{ + // @ts-expect-error CSS variable + "--cols": `repeat(${cols}, minmax(0, 1fr))` + }} + className="grid gap-2 grid-cols-1 sm:grid-cols-(--cols)" > - {displayedProvidedOptions.map((option) => ( -
- } - buttonText={t("saveSettings")} - onConfirm={() => performSave(form.getValues())} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - - - - - - - {t("general")} - - - {t("orgGeneralSettingsDescription")} - - - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - -
- - - - {t("logRetention")} - - - {t("logRetentionDescription")} - - - - - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build !== "oss" && ( - <> - - - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - - - {build !== "oss" && ( - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = !isPaidUser; - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "maxSessionLength" - )} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = !isPaidUser; - - return ( - - - {t( - "passwordExpiryDays" - )} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> -
-
-
- )} - -
- -
-
- - - - {build !== "saas" && ( - - - - {t("dangerSection")} - - - {t("dangerSectionDescription")} - - -
- -
-
- )} - - ); -} - -function GeneralSectionForm() { - const { org } = useOrgContext(); - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - name: org?.org.name - }, - mode: "onChange" - }); - const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); - const { isPaidUser } = usePaidStatus(); - return ( - <> - {t("general")} + + {t("dangerSection")} + - {t("orgGeneralSettingsDescription")} + {t("dangerSectionDescription")} - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - -
+
@@ -915,19 +238,180 @@ function GeneralSectionForm() { ); } -function LogRetentionSectionForm() { - const { org } = useOrgContext(); +function GeneralSectionForm({ org }: SectionFormProps) { const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + resolver: zodResolver( + GeneralFormSchema.pick({ + name: true, + subnet: true + }) + ), defaultValues: { - name: org?.org.name + name: org.name, + subnet: org.subnet || "" // Add default value for subnet }, mode: "onChange" }); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); - const { isPaidUser } = usePaidStatus(); + const router = useRouter(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + name: data.name + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("general")} + + {t("orgGeneralSettingsDescription")} + + + + +
+ + ( + + {t("name")} + + + + + + {t("orgDisplayName")} + + + )} + /> + ( + + {t("subnet")} + + + + + + {t("subnetDescription")} + + + )} + /> + + +
+
+ +
+ +
+
+ ); +} + +function LogRetentionSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + GeneralFormSchema.pick({ + settingsLogRetentionDaysRequest: true, + settingsLogRetentionDaysAccess: true, + settingsLogRetentionDaysAction: true + }) + ), + defaultValues: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.settingsLogRetentionDaysAction ?? 15 + }, + mode: "onChange" + }); + + const router = useRouter(); + const t = useTranslations(); + const { isPaidUser, hasSaasSubscription } = usePaidStatus(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } return ( @@ -939,421 +423,566 @@ function LogRetentionSectionForm() { - ( - - - {t("logRetentionRequestLabel")} - - - + field.onChange( + parseInt(value, 10) + ) } - ).map((option) => ( - - {t(option.label)} - - ))} - - - - - - )} - /> - - {build != "oss" && ( - <> - - - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t("logRetentionAccessLabel")} - - - - - - - ); - }} - /> - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t("logRetentionActionLabel")} - - - - - - - ); - }} + ).map((option) => ( + + {t(option.label)} + + ))} + + + + + + )} /> - - )} + + {build !== "oss" && ( + <> + + + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + + +
+ +
); } -function SectionForm() { - const { org } = useOrgContext(); +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + resolver: zodResolver( + GeneralFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), defaultValues: { - name: org?.org.name + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null }, mode: "onChange" }); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); const { isPaidUser } = usePaidStatus(); - return ( - - {build !== "oss" && ( - <> -
- - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = - isSecurityFeatureDisabled(); + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; - return ( - -
+ const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + + const [, formAction, loadingSave] = useActionState(onSubmit, null); + const api = createApiClient(useEnvContext()); + + const formRef = useRef>(null); + + async function onSubmit() { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(); + } + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + requireTwoFactor: data.requireTwoFactor || false, + maxSessionLengthHours: data.maxSessionLengthHours, + passwordExpiryDays: data.passwordExpiryDays + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + <> + +

{t("securityPolicyChangeDescription")}

+
+ } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + { + const isDisabled = !isPaidUser; + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("maxSessionLength")} + - { if (!isDisabled) { + const numValue = + value === + "null" + ? null + : parseInt( + value, + 10 + ); form.setValue( - "requireTwoFactor", - val + "maxSessionLengthHours", + numValue ); } }} - /> + disabled={isDisabled} + > + + + + + {SESSION_LENGTH_OPTIONS.map( + (option) => ( + + {t( + option.labelKey + )} + + ) + )} + + -
- - - {t( - "requireTwoFactorDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("passwordExpiryDays")} - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> - - - - )} -
+ + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser; + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + + + + +
+ +
+ + ); } diff --git a/src/contexts/orgContext.ts b/src/contexts/orgContext.ts index dbe92d50..e5141bde 100644 --- a/src/contexts/orgContext.ts +++ b/src/contexts/orgContext.ts @@ -1,9 +1,8 @@ import { GetOrgResponse } from "@server/routers/org"; import { createContext } from "react"; -interface OrgContextType { +export interface OrgContextType { org: GetOrgResponse; - updateOrg: (updateOrg: Partial) => void; } const OrgContext = createContext(undefined); diff --git a/src/providers/OrgProvider.tsx b/src/providers/OrgProvider.tsx index adceeff0..122e0127 100644 --- a/src/providers/OrgProvider.tsx +++ b/src/providers/OrgProvider.tsx @@ -10,36 +10,15 @@ interface OrgProviderProps { org: GetOrgResponse | null; } -export function OrgProvider({ children, org: serverOrg }: OrgProviderProps) { - const [org, setOrg] = useState(serverOrg); - +export function OrgProvider({ children, org }: OrgProviderProps) { const t = useTranslations(); if (!org) { throw new Error(t("orgErrorNoProvided")); } - const updateOrg = (updatedOrg: Partial) => { - if (!org) { - throw new Error(t("orgErrorNoUpdate")); - } - - setOrg((prev) => { - if (!prev) { - return prev; - } - - return { - ...prev, - ...updatedOrg - }; - }); - }; - return ( - - {children} - + {children} ); } From 137d6c25239d6f38ffff190d5af50a9e094c0c2b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 18 Dec 2025 04:36:09 +0100 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix=20typescript?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/commands/clearExitNodes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/commands/clearExitNodes.ts b/cli/commands/clearExitNodes.ts index 092e9761..2a283203 100644 --- a/cli/commands/clearExitNodes.ts +++ b/cli/commands/clearExitNodes.ts @@ -23,9 +23,9 @@ export const clearExitNodes: CommandModule< // Delete all exit nodes const deletedCount = await db .delete(exitNodes) - .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)); // delete all + .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all - console.log(`Deleted ${deletedCount.changes} exit node(s) from the database`); + console.log(`Deleted ${deletedCount.length} exit node(s) from the database`); process.exit(0); } catch (error) {