"use client"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Button } from "@app/components/ui/button"; import { InfoPopup } from "@app/components/ui/info-popup"; import { SettingsContainer } from "@app/components/Settings"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { ListResourcesResponse } from "@server/routers/resource"; import type ResponseT from "@server/types/Response"; import { useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams } from "next/navigation"; import { toUnicode } from "punycode"; import { useMemo, useState, type ReactNode } from "react"; const INITIAL_PAGE_SIZE = 5; const LOAD_MORE_INCREMENT = 20; type SiteResourceRow = ListAllSiteResourcesByOrgResponse["siteResources"][number]; type PublicResourceRow = ListResourcesResponse["resources"][number]; function isForbidden(e: unknown): boolean { return isAxiosError(e) && e.response?.status === 403; } function isSafeUrlForLink(href: string): boolean { try { void new URL(href); return true; } catch { return false; } } /** Meta text inside the left column (width comes from the column wrapper). */ const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm"; function publicProtocolLabel(r: PublicResourceRow): string { if (r.http) { return r.ssl ? "HTTPS" : "HTTP"; } const p = (r.protocol || "").toLowerCase(); if (p === "tcp") return "TCP"; if (p === "udp") return "UDP"; return (r.protocol || "—").toUpperCase(); } function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) { return (
{publicProtocolLabel(r)}
); } function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { const t = useTranslations(); const modeLabel: Record = { host: t("editInternalResourceDialogModeHost"), cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp") }; const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, httpHttpsPort: row.destinationPort ?? null, scheme: row.scheme }); return (
{modeLabel[row.mode]}
); } function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) { const t = useTranslations(); if (!r.http) { return ( ); } if (!r.domainId) { return ( ); } const fullUrl = `${r.ssl ? "https" : "http"}://${toUnicode(r.fullDomain || "")}`; return ( ); } function PrivateAccessMethod({ row }: { row: SiteResourceRow }) { if (row.mode === "http" && row.fullDomain) { const url = `${row.ssl ? "https" : "http"}://${toUnicode(row.fullDomain)}`; return ( ); } if (row.mode === "host" && row.alias) { return ( ); } const fromAlias = row.alias?.trim(); if (fromAlias) { return ( ); } const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, httpHttpsPort: row.destinationPort, scheme: row.scheme }); return ( ); } type OverviewRow = { key: number; meta: ReactNode; name: string; access: ReactNode; editHref: string; }; type OverviewColumnProps = { title: string; description: string; viewAllHref: string; viewAllLabel: string; emptyLabel: string; isForbidden: boolean; isFetching: boolean; /** When there are no rows and the first fetch (no SSR initial data) is in flight. */ isLoading: boolean; rows: OverviewRow[]; canShowMore: boolean; onShowMore: () => void; }; function OverviewColumn({ title, description, viewAllHref, viewAllLabel, emptyLabel, isForbidden, isFetching, isLoading, rows, canShowMore, onShowMore }: OverviewColumnProps) { const t = useTranslations(); const header = (

{title}

{description}

{viewAllLabel}
); if (isForbidden) { return (
{header}

{t("siteResourcesPermissionDenied")}

); } return (
{header} {rows.length === 0 ? (
{isLoading ? (
{t("loading")}
) : (

{emptyLabel}

)}
) : ( <>
    {rows.map((row) => (
  • {row.meta}
    {row.name}
    {row.access}
  • ))}
{canShowMore ? (
) : null} )}
); } type SiteResourcesOverviewProps = { siteId: number; initialPublicData: ListResourcesResponse | null; initialPrivateData: ListAllSiteResourcesByOrgResponse | null; initialPublicForbidden: boolean; initialPrivateForbidden: boolean; /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ orgIdOverride?: string; }; export default function SiteResourcesOverview({ siteId, initialPublicData, initialPrivateData, initialPublicForbidden, initialPrivateForbidden, orgIdOverride }: SiteResourcesOverviewProps) { const t = useTranslations(); const params = useParams<{ orgId: string }>(); const orgId = orgIdOverride ?? params.orgId; const { env } = useEnvContext(); const api = useMemo(() => createApiClient({ env }), [env]); const enabled = Boolean(orgId && siteId); const [publicPageSize, setPublicPageSize] = useState(INITIAL_PAGE_SIZE); const [privatePageSize, setPrivatePageSize] = useState(INITIAL_PAGE_SIZE); const publicQuery = useQuery({ queryKey: [ "siteResourcesOverview", "public", orgId, siteId, publicPageSize ] as const, enabled: enabled && !initialPublicForbidden, initialData: initialPublicData ?? undefined, queryFn: async (): Promise => { const sp = new URLSearchParams({ page: "1", pageSize: String(publicPageSize), siteId: String(siteId) }); const res = await api.get( `/org/${orgId}/resources?${sp.toString()}` ); const envelope = res.data as ResponseT; const payload = envelope.data; if (!payload) { throw new Error("No data"); } return payload; } }); const privateQuery = useQuery({ queryKey: [ "siteResourcesOverview", "private", orgId, siteId, privatePageSize ] as const, enabled: enabled && !initialPrivateForbidden, initialData: initialPrivateData ?? undefined, queryFn: async (): Promise => { const sp = new URLSearchParams({ page: "1", pageSize: String(privatePageSize), siteId: String(siteId) }); const res = await api.get( `/org/${orgId}/site-resources?${sp.toString()}` ); const envelope = res.data as ResponseT; const payload = envelope.data; if (!payload) { throw new Error("No data"); } return payload; } }); const publicList = publicQuery.data?.resources ?? []; const publicTotal = publicQuery.data?.pagination.total ?? 0; const privateList = privateQuery.data?.siteResources ?? []; const privateTotal = privateQuery.data?.pagination.total ?? 0; const publicForbidden = initialPublicForbidden || (publicQuery.isError && isForbidden(publicQuery.error)); const privateForbidden = initialPrivateForbidden || (privateQuery.isError && isForbidden(privateQuery.error)); const waitingOnPublicList = enabled && !publicForbidden && publicQuery.isPending; const waitingOnPrivateList = enabled && !privateForbidden && privateQuery.isPending; const showEmptyPlaceholder = !waitingOnPublicList && !waitingOnPrivateList && !publicForbidden && !privateForbidden && publicList.length === 0 && privateList.length === 0; const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`; const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`; const publicRows = publicList.map((r) => ({ key: r.resourceId, meta: , name: r.name, access: , editHref: `/${orgId}/settings/resources/proxy/${r.niceId}` })); const privateRows = privateList.map((row) => { const qs = new URLSearchParams({ siteId: String(siteId), query: row.niceId }); return { key: row.siteResourceId, meta: , name: row.name, access: , editHref: `/${orgId}/settings/resources/client?${qs.toString()}` }; }); if (showEmptyPlaceholder) { return (

{t("siteResourcesNoneOnSite")}

); } const publicEmptyLoading = enabled && !publicForbidden && publicRows.length === 0 && publicQuery.isPending; const privateEmptyLoading = enabled && !privateForbidden && privateRows.length === 0 && privateQuery.isPending; const publicColumn = ( setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)} /> ); const privateColumn = ( setPrivatePageSize((n) => n + LOAD_MORE_INCREMENT) } /> ); return (
{publicColumn} {privateColumn}
); }