Merge branch 'dev' into #3001

This commit is contained in:
miloschwartz
2026-07-08 12:10:13 -04:00
701 changed files with 73803 additions and 23250 deletions
+117
View File
@@ -0,0 +1,117 @@
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export default async function AuthFooter() {
const env = pullEnv();
const t = await getTranslations();
let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build === "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
licenseStatus = licenseStatusRes.data.data;
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) {
hideFooter = true;
}
}
if (hideFooter) return null;
return (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>© {new Date().getFullYear()} Fossorial, Inc.</span>
</a>
{build !== "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
{process.env.BRANDING_APP_NAME || "Pangolin"}
</span>
</a>
</>
)}
<Separator orientation="vertical" />
<span>
{build === "oss"
? t("communityEdition")
: build === "enterprise"
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("termsOfService")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("privacyPolicy")}</span>
</a>
</>
)}
</div>
</footer>
);
}
+3 -4
View File
@@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type AuthPageCustomizationProps = {
orgId: string;
@@ -92,7 +91,7 @@ export default function AuthPageBrandingForm({
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
resourceTitle:
branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`,
`Authenticate to Access {{resourceName}}`,
resourceSubtitle:
branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`,
+40
View File
@@ -0,0 +1,40 @@
"use client";
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
export default function AuthPageFooterNotices() {
const t = useTranslations();
// const { supporterStatus } = useSupporterStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
return (
<>
{/* {supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t("noSupportKey")}
</span>
</div>
)} */}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("instanceIsUnlicensed")}
</span>
</div>
) : null}
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("loginPageLicenseWatermark")}
</span>
</div>
) : null}
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type BrandedAuthSurfaceProps = {
primaryColor?: string | null;
children: React.ReactNode;
};
export default function BrandedAuthSurface({
primaryColor,
children
}: BrandedAuthSurfaceProps) {
const { isUnlocked } = useLicenseStatusContext();
return (
<div
style={{
// @ts-expect-error CSS variable
"--primary": isUnlocked() ? primaryColor : null
}}
>
{children}
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { cn } from "@app/lib/cn";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { useWatch } from "react-hook-form";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type BaseProps<T extends FieldValues> = {
control: Control<T>;
orgId: string;
destinationField: Path<T>;
destinationPortField: Path<T>;
learnMoreHref?: string;
defaultPort: number;
};
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite: true;
sitesField: Path<T>;
};
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite?: false;
siteField: Path<T>;
};
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
| MultiSiteFormProps<T>
| SingleSiteFormProps<T>;
export function BrowserGatewayTargetForm<T extends FieldValues>(
props: BrowserGatewayTargetFormProps<T>
) {
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
// Opt out of the React Compiler for this component.
//
// The parent (create page) shares a single `bgTargetForm` instance across
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
// resource type changes. react-hook-form's Controller uses an external
// subscription that the React Compiler cannot statically reason about, so
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
// the render prop and skip re-rendering the <Input> elements when their
// bound form values change. The visible symptom is that typing into the
// destination/port inputs updates form state but the input itself never
// visually updates. The escape hatch is the canonical fix here.
"use no memo";
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const sitesFieldName =
props.multiSite === true ? props.sitesField : props.siteField;
// Subscribe to field values via useWatch and drive the controlled <Input>
// elements from these values rather than from the `field.value` returned
// by the Controller render prop. Combined with the "use no memo" directive
// above, this makes the inputs reliably re-render when their bound form
// values change.
const watchedSites = useWatch({
control: props.control,
name: sitesFieldName
});
const watchedDestination = useWatch({
control: props.control,
name: props.destinationField
});
const watchedDestinationPort = useWatch({
control: props.control,
name: props.destinationPortField
});
const showMultiSiteDisclaimer =
props.multiSite === true &&
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4 items-start">
<FormField
control={props.control}
name={sitesFieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{t("sites")}</FormLabel>
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
props.multiSite === true
? (
field.value as Selectedsite[]
)?.length === 0 &&
"text-muted-foreground"
: !field.value &&
"text-muted-foreground"
)}
>
<span className="truncate">
{props.multiSite === true
? formatMultiSitesSelectorLabel(
(field.value as Selectedsite[]) ??
[],
t
)
: ((
field.value as Selectedsite | null
)?.name ??
t("siteSelect"))}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
{props.multiSite === true ? (
<MultiSitesSelector
orgId={props.orgId}
selectedSites={
(field.value as Selectedsite[]) ??
[]
}
onSelectionChange={field.onChange}
filterTypes={["newt"]}
/>
) : (
<SitesSelector
orgId={props.orgId}
selectedSite={
field.value as Selectedsite | null
}
onSelectSite={(site) => {
field.onChange(site);
setSiteOpen(false);
}}
filterTypes={["newt"]}
/>
)}
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("destination")}</FormLabel>
<FormControl>
<Input
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestination as
| string
| undefined) ?? ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationPortField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("port")}</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestinationPort as
| string
| number
| undefined) ?? ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{showMultiSiteDisclaimer && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/private/multi-site-routing"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
)}
</div>
);
}
+12 -13
View File
@@ -27,20 +27,18 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSections cols={userDisplayName ? 3 : 2}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
{userDisplayName ? (
<InfoSection>
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName}</span>
{(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
@@ -54,9 +52,10 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
</div>
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
-676
View File
@@ -1,676 +0,0 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type InternalResourceSiteRow = ResourceSiteRow;
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
sites: InternalResourceSiteRow[];
siteNames: string[];
siteAddresses: (string | null)[];
siteIds: number[];
siteNiceIds: string[];
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null;
// proxyPort: number | null;
destination: string;
httpHttpsPort: number | null;
alias: string | null;
aliasAddress: string | null;
niceId: string;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
};
function formatDestinationDisplay(row: InternalResourceRow): string {
return formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.httpHttpsPort,
scheme: row.scheme
});
}
function isSafeUrlForLink(href: string): boolean {
try {
void new URL(href);
return true;
} catch {
return false;
}
}
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterSite?: Selectedsite | null;
};
export default function ClientResourcesTable({
internalResources,
orgId,
pagination,
rowCount,
initialFilterSite = null
}: ClientResourcesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
await api.delete(`/site-resource/${resourceId}`).then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
} catch (e) {
console.error(t("resourceErrorDelete"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("v"))
});
}
};
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {
return (
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<span>
{siteNames.length} {t("sites")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
>
{siteName}
<ArrowUpRight className="h-3 w-3" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
function handleFilterChange(
column: string,
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (value) {
searchParams.set(column, value);
}
filter({
searchParams
});
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteIds[0]
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<ControlledDataTable
columns={internalColumns}
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query") ?? ""}
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onSearch={handleSearchChange}
onRefresh={refreshData}
onPaginationChange={handlePaginationChange}
pagination={pagination}
rowCount={rowCount}
isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility
columnVisibility={{
niceId: false,
aliasAddress: false
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
export default function CollapsibleSessionToolbar({
children,
defaultOpen = false
}: {
children: ReactNode;
defaultOpen?: boolean;
}) {
const t = useTranslations();
const [open, setOpen] = useState(defaultOpen);
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10">
<div
className={cn(
"pointer-events-auto absolute inset-x-0 top-0 isolate transition-transform duration-200 ease-out",
open ? "translate-y-0" : "-translate-y-full"
)}
>
<div className="relative z-20 flex flex-wrap items-center gap-2 bg-background p-2">
{children}
</div>
{/* Secondary toggle backdrop kept as a distinct style under
the main handle button. */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
}
aria-expanded={open}
className="absolute left-1/2 top-full -z-20 h-4 w-72 -translate-x-1/2 -translate-y-2 rounded-md bg-neutral-200 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:bg-neutral-500"
/>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
}
aria-expanded={open}
className="absolute left-1/2 top-full -z-10 flex h-5 w-6 -translate-x-1/2 items-center justify-center rounded-b-sm bg-primary text-primary-foreground transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
</div>
</div>
);
}
+5 -3
View File
@@ -17,6 +17,7 @@ import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
import { useTranslations } from "next-intl";
interface FilterOption {
value: string;
@@ -27,7 +28,6 @@ interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
@@ -38,7 +38,6 @@ export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
@@ -50,6 +49,8 @@ export function ColumnFilterButton({
(option) => option.value === selectedValue
);
const t = useTranslations();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -94,7 +95,7 @@ export function ColumnFilterButton({
}}
className="text-muted-foreground"
>
Clear filter
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
@@ -109,6 +110,7 @@ export function ColumnFilterButton({
);
setOpen(false);
}}
className="break-all"
>
<CheckIcon
className={cn(
+8 -8
View File
@@ -20,6 +20,7 @@ import { CheckIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
import { Checkbox } from "./ui/checkbox";
type FilterOption = {
value: string;
@@ -119,7 +120,7 @@ export function ColumnMultiFilterButton({
}}
className="text-muted-foreground"
>
{t("accessUsersRoleFilterClear")}
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
@@ -129,14 +130,13 @@ export function ColumnMultiFilterButton({
onSelect={() => {
toggle(option.value);
}}
className="break-all"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSet.has(option.value)
? "opacity-100"
: "opacity-0"
)}
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedSet.has(option.value)}
aria-hidden
tabIndex={-1}
/>
{option.label}
</CommandItem>
+1 -1
View File
@@ -130,7 +130,7 @@ const DockerContainersTable: FC<{
useState(true);
const [hideStoppedContainers, setHideStoppedContainers] = useState(false);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
labels: false
labels: true
});
const t = useTranslations();
+12 -3
View File
@@ -1,3 +1,4 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
className?: string;
};
const CopyToClipboard = ({
text,
displayText,
isLink
isLink,
className
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline text-sm min-w-0 max-w-full"
className={cn(
"truncate hover:underline text-sm min-w-0 max-w-full",
className
)}
title={text} // Shows full text on hover
>
{displayValue}
</Link>
) : (
<span
className="truncate text-sm min-w-0 max-w-full"
className={cn(
"truncate text-sm min-w-0 max-w-full",
className
)}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
+1 -1
View File
@@ -109,7 +109,7 @@ export default function CreateBlueprintForm({
if (res && res.status === 201) {
const createdBlueprint = res.data.data;
toast({
variant: "warning",
variant: createdBlueprint.succeeded ? "default" : "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message
});
@@ -1,182 +0,0 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
cleanForFQDN,
InternalResourceForm,
isHostname,
type InternalResourceFormValues
} from "./InternalResourceForm";
type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export default function CreateInternalResourceDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true);
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
`/org/${orgId}/site-resource`,
{
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
destination: data.destination,
enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? undefined,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" || data.mode == "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],
userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
}
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<InternalResourceForm
variant="create"
open={open}
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export function CreateOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("createLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+13 -18
View File
@@ -16,14 +16,16 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type {
CreateRoleBody,
CreateRoleResponse
} from "@server/routers/role";
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
@@ -50,29 +52,22 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.sshPam)) {
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
.put<AxiosResponse<CreateRoleResponse>>(
`/org/${org?.org.orgId}/role`,
payload
)
.put<
AxiosResponse<CreateRoleResponse>
>(`/org/${org?.org.orgId}/role`, payload)
.catch((e) => {
toast({
variant: "destructive",
+31 -7
View File
@@ -4,6 +4,7 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -89,7 +90,7 @@ export default function CreateShareLinkForm({
const t = useTranslations();
const { data: allResources = [] } = useQuery(
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" })
);
const [selectedResource, setSelectedResource] =
@@ -112,6 +113,7 @@ export default function CreateShareLinkForm({
resourceId: z.number({ message: t("shareErrorSelectResource") }),
resourceName: z.string(),
resourceUrl: z.string(),
path: z.string().optional(),
timeUnit: z.string(),
timeValue: z.coerce.number<number>().int().positive().min(1),
title: z.string().optional()
@@ -172,7 +174,8 @@ export default function CreateShareLinkForm({
resource:
values.resourceName ||
"Resource" + values.resourceId
})
}),
path: values.path
}
)
.catch((e) => {
@@ -267,11 +270,11 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
excludeWildcard
orgId={
org.org
.orgId
}
excludeWildcard
orgId={
org.org
.orgId
}
selectedResource={
selectedResource
}
@@ -320,6 +323,27 @@ export default function CreateShareLinkForm({
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sharePathOptional")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"sharePathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>
+1 -1
View File
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0 md:overflow-hidden",
className
)}
{...props}
+17 -1
View File
@@ -318,12 +318,28 @@ export default function DeviceLoginForm({
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={9}
maxLength={8}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
{...field}
value={field.value
.replace(/-/g, "")
.toUpperCase()}
onPaste={(event) => {
event.preventDefault();
const pastedText =
event.clipboardData.getData(
"text"
);
const cleanedValue =
pastedText
.replace(
/[^a-zA-Z0-9]/g,
""
)
.toUpperCase()
.slice(0, 8);
field.onChange(cleanedValue);
}}
onChange={(value) => {
// Strip hyphens and convert to uppercase
const cleanedValue = value
@@ -1,204 +0,0 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
InternalResourceForm,
type InternalResourceData,
type InternalResourceFormValues,
isHostname
} from "./InternalResourceForm";
type EditInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
resource: InternalResourceData;
orgId: string;
onSuccess?: () => void;
};
export default function EditInternalResourceDialog({
open,
setOpen,
resource,
orgId,
onSuccess
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
niceId: data.niceId,
destination: data.destination,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
});
await queryClient.invalidateQueries(
resourceQueries.siteResourceRoles({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceUsers({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceClients({
siteResourceId: resource.id
})
);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
}
}
return (
<Credenza
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) setOpen(false);
}}
>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"editInternalResourceDialogUpdateResourceProperties",
{
resourceName: resource.name
}
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<InternalResourceForm
variant="edit"
open={open}
resource={resource}
orgId={orgId}
siteResourceId={resource.id}
formId="edit-internal-resource-form"
onSubmit={(values) =>
startTransition(() => handleSubmit(values))
}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
label: {
name: string;
color: string;
labelId: number;
};
};
export function EditOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess,
label
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.patch<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/label/${label.labelId}`, data);
if (res.status === 200) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelEditSuccessMessage")
});
}
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("editLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
defaultValue={label}
onSubmit={(data) => {
startTransition(async () => editOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+13 -18
View File
@@ -16,14 +16,16 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { Role } from "@server/db";
import type {
UpdateRoleBody,
UpdateRoleResponse
} from "@server/routers/role";
import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import {
parseSudoCommands,
parseUnixGroups,
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = {
@@ -53,29 +55,22 @@ export default function EditRoleForm({
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.sshPam)) {
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
.post<AxiosResponse<UpdateRoleResponse>>(
`/role/${role.roleId}`,
payload
)
.post<
AxiosResponse<UpdateRoleResponse>
>(`/role/${role.roleId}`, payload)
.catch((e) => {
toast({
variant: "destructive",
+2 -2
View File
@@ -122,7 +122,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
@@ -131,7 +131,7 @@ export default function ExitNodesTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("online")}
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
+9 -9
View File
@@ -422,7 +422,7 @@ export default function GenerateLicenseKeyForm({
{part}
{index === 0 && (
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -601,7 +601,7 @@ export default function GenerateLicenseKeyForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -614,7 +614,7 @@ export default function GenerateLicenseKeyForm({
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -658,12 +658,12 @@ export default function GenerateLicenseKeyForm({
license
details:{" "}
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
https://pangolin.net/fcl
</a>
</div>
</FormLabel>
@@ -987,7 +987,7 @@ export default function GenerateLicenseKeyForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -1000,7 +1000,7 @@ export default function GenerateLicenseKeyForm({
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -1044,12 +1044,12 @@ export default function GenerateLicenseKeyForm({
license
details:{" "}
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
https://pangolin.net/fcl
</a>
</div>
</FormLabel>
+77 -52
View File
@@ -2,24 +2,36 @@
import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps {
value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string;
rows?: number;
className?: string;
}
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({
value = [],
onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`,
rows = 4,
className
}: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false);
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
.join("\n");
};
// Convert newline-separated string to header objects array
const convertToHeadersArray = (
// Parse newline-separated text into header objects, validating each line
// against the same rules enforced by the server. Returns either the
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
newlineSeparated: string
): { name: string; value: string }[] | null => {
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
):
| { headers: { name: string; value: string }[]; error: null }
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
return newlineSeparated
const lines = newlineSeparated
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && line.includes(":"))
.map((line) => {
const colonIndex = line.indexOf(":");
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
.filter((line) => line.length > 0);
// Ensure header name conforms to HTTP header requirements
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
const normalizedName = name
.replace(/[^a-zA-Z0-9\-]/g, "")
.toLowerCase();
const headers: { name: string; value: string }[] = [];
return { name: normalizedName, value };
})
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
for (const line of lines) {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
if (
!validHeaderNamePattern.test(name) ||
!validHeaderValuePattern.test(value) ||
templatePattern.test(name) ||
templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value });
}
return { headers, error: null };
};
// Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused)
useEffect(() => {
if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value));
setInternalValue(convertToNewlineSeparated(value ?? []));
setError(null);
onValidityChange?.(true);
}
}, [value]);
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
// Mark that user is actively editing
isUserEditingRef.current = true;
// Only update parent if the input is in a valid state
// Valid states: empty/whitespace only, or contains properly formatted headers
const result = parseHeaders(newValue);
if (newValue.trim() === "") {
// Empty input is valid - represents no headers
onChange([]);
} else {
// Check if all non-empty lines are properly formatted (contain ':')
const lines = newValue.split("\n");
const nonEmptyLines = lines
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
if (result.error) {
// Surface the error and do not touch the last known good value.
// Silently dropping the update here (without telling the user)
// is what previously let stale data get saved without warning.
setError(result.error);
onValidityChange?.(false);
return;
}
setError(null);
onValidityChange?.(true);
onChange(result.headers);
};
const handleFocus = () => {
@@ -114,15 +134,20 @@ X-Another-Header: another-value`,
};
return (
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
<div>
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -109,7 +109,6 @@ export default function HealthChecksTable({
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const pageSize = pagination.pageSize;
const query = searchParams.get("query") ?? undefined;
const siteIdQ = searchParams.get("siteId");
@@ -164,12 +163,12 @@ export default function HealthChecksTable({
});
}
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString());
@@ -407,7 +406,7 @@ export default function HealthChecksTable({
}
return (
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
>
<Button variant="outline" size="sm">
{r.resourceName}
@@ -586,7 +585,9 @@ export default function HealthChecksTable({
<Switch
checked={r.hcEnabled}
disabled={
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId
!isPaid ||
togglingId === r.targetHealthCheckId ||
!!r.resourceId
}
onCheckedChange={(v) => handleToggleEnabled(r, v)}
/>
@@ -626,7 +627,7 @@ export default function HealthChecksTable({
</DropdownMenu>
{r.resourceId && r.resourceName && r.resourceNiceId ? (
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
>
<Button variant="outline" disabled={!isPaid}>
{t("edit")}
+11 -3
View File
@@ -5,12 +5,15 @@ import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols,
columnSizing = "content"
columnSizing = "content",
layout = "default"
}: {
children: React.ReactNode;
cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content";
/** panel: fixed 2-column grid for narrow containers such as side panels */
layout?: "default" | "panel";
}) {
const n = cols || 1;
const track =
@@ -19,9 +22,14 @@ export function InfoSections({
return (
<div
className={cn(
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
"grid w-full min-w-0 gap-4",
layout === "panel"
? "grid-cols-2 items-start"
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
columnSizing === "content" &&
"md:justify-items-start md:justify-start"
(layout === "panel"
? "justify-items-start justify-start"
: "md:justify-items-start md:justify-start")
)}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { Funnel } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { LabelBadge } from "./label-badge";
import { LabelOverflowBadge } from "./label-overflow-badge";
import { LabelsFilterSelector } from "./LabelsFilterSelector";
import { LABEL_COLORS } from "./labels-selector";
function areSelectionsEqual(a: string[], b: string[]) {
if (a.length !== b.length) {
return false;
}
const setB = new Set(b);
return a.every((value) => setB.has(value));
}
type LabelColumnFilterButtonProps = {
selectedValues: string[];
onSelectedValuesChange: (values: string[]) => void;
className?: string;
label: string;
orgId: string;
};
export function LabelColumnFilterButton({
selectedValues,
onSelectedValuesChange,
className,
label,
orgId
}: LabelColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
const t = useTranslations();
const { data: labels = [] } = useQuery(
orgQueries.labels({
orgId,
perPage: 500
})
);
const draftSet = useMemo(() => new Set(draftValues), [draftValues]);
const selectedLabels = useMemo(
() =>
selectedValues.map((name) => {
const foundLabel = labels.find((label) => label.name === name);
return {
name,
color: foundLabel?.color ?? LABEL_COLORS.gray
};
}),
[selectedValues, labels]
);
const summary = useMemo(() => {
if (selectedLabels.length === 0) {
return null;
}
if (selectedLabels.length === 1) {
const label = selectedLabels[0];
return (
<LabelBadge
displayOnly
name={label.name}
color={label.color}
className="shrink-0"
/>
);
}
return (
<LabelOverflowBadge
labels={selectedLabels}
displayOnly
className="shrink-0"
/>
);
}, [selectedLabels]);
function toggle(value: string) {
setDraftValues((current) =>
current.includes(value)
? current.filter((v) => v !== value)
: [...current, value]
);
}
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setDraftValues(selectedValues);
setOpen(true);
return;
}
setOpen(false);
if (!areSelectionsEqual(draftValues, selectedValues)) {
onSelectedValuesChange(draftValues);
}
}
return (
<div className="flex items-center">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
selectedValues.length === 0 &&
"text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{label}</span>
<Funnel className="size-4 flex-none shrink-0" />
{summary}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<LabelsFilterSelector
orgId={orgId}
isSelected={(label) => draftSet.has(label.name)}
onToggle={(label) => {
toggle(label.name);
}}
showClear={draftValues.length > 0}
onClear={() => {
setDraftValues([]);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { Checkbox } from "./ui/checkbox";
export type LabelFilterOption = {
labelId: number;
name: string;
color: string;
};
type LabelsFilterSelectorProps = {
orgId: string;
isSelected: (label: LabelFilterOption) => boolean;
onToggle: (label: LabelFilterOption) => void;
selectedLabels?: LabelFilterOption[];
onClear?: () => void;
showClear?: boolean;
scope?: "org" | "launcher";
};
export function LabelsFilterSelector({
orgId,
isSelected,
onToggle,
selectedLabels = [],
onClear,
showClear = false,
scope = "org"
}: LabelsFilterSelectorProps) {
const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
const orgLabelsQuery = useQuery({
...orgQueries.labels({
orgId,
query: debouncedQuery,
perPage: 500
}),
enabled: scope === "org"
});
const launcherLabelsQuery = useQuery({
...launcherQueries.labels({
orgId,
query: debouncedQuery,
perPage: 20
}),
enabled: scope === "launcher"
});
const labels =
scope === "launcher"
? (launcherLabelsQuery.data ?? [])
: (orgLabelsQuery.data ?? []);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(selected) =>
!base.some((label) => label.labelId === selected.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{labelsShown.map((label) => (
<CommandItem
key={label.labelId}
value={label.name}
onSelect={() => {
onToggle(label);
}}
className="flex items-center gap-2"
>
<Checkbox
className="pointer-events-none shrink-0"
checked={isSelected(label)}
aria-hidden
tabIndex={-1}
/>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import type { Measurable } from "@radix-ui/rect";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { LabelBadge } from "./label-badge";
import { LabelOverflowBadge } from "./label-overflow-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Button } from "./ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "./ui/popover";
const MAX_VISIBLE_LABELS = 4;
const MAX_VISIBLE_BEFORE_OVERFLOW = MAX_VISIBLE_LABELS - 1;
type TableLabelsCellProps = {
orgId: string;
selectedLabels: SelectedLabel[];
onToggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void;
onClosePopover: () => void;
};
export function LabelsTableCell({
orgId,
selectedLabels,
onToggleLabel,
onClosePopover
}: TableLabelsCellProps) {
const t = useTranslations();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const frozenAnchorRef = useRef<Measurable>({
getBoundingClientRect: () => new DOMRect()
});
const hasOverflow = selectedLabels.length > MAX_VISIBLE_LABELS;
const visibleLabels = selectedLabels.slice(
0,
hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS
);
const overflowLabels = hasOverflow
? selectedLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW)
: [];
function handleOpenChange(open: boolean) {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
frozenAnchorRef.current = {
getBoundingClientRect: () => rect
};
}
setIsPopoverOpen(open);
if (!open) {
onClosePopover();
}
}
return (
<div className="flex items-center gap-1">
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
<PopoverAnchor virtualRef={frozenAnchorRef} />
<PopoverTrigger asChild>
<Button
ref={triggerRef}
size="icon"
variant="outline"
className="size-auto shrink-0 rounded-full p-1"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className={`${dataTableFilterPopoverContentClassName} p-0`}
updatePositionStrategy="optimized"
>
<LabelsSelector
orgId={orgId}
selectedLabels={selectedLabels}
toggleLabel={onToggleLabel}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-nowrap items-center justify-start gap-1 overflow-hidden">
{visibleLabels.map((label) => (
<LabelBadge
key={label.labelId}
className="shrink-0"
onClick={() => handleOpenChange(true)}
{...label}
/>
))}
{overflowLabels.length > 0 && (
<LabelOverflowBadge
labels={overflowLabels}
onClick={() => handleOpenChange(true)}
/>
)}
</div>
</div>
);
}
+66 -40
View File
@@ -1,21 +1,28 @@
import React from "react";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import type { SidebarNavSection } from "@app/app/navigation";
import type {
CommandBarNavSection,
SidebarNavSection
} from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
import { cookies } from "next/headers";
import { CommandPaletteProvider } from "./command-palette/CommandPalette";
interface LayoutProps {
children: React.ReactNode;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems?: SidebarNavSection[];
commandNavItems?: CommandBarNavSection[];
showSidebar?: boolean;
showHeader?: boolean;
showTopBar?: boolean;
defaultSidebarCollapsed?: boolean;
launcherMode?: boolean;
showViewAsAdmin?: boolean;
}
export async function Layout({
@@ -23,10 +30,13 @@ export async function Layout({
orgId,
orgs,
navItems = [],
commandNavItems = [],
showSidebar = true,
showHeader = true,
showTopBar = true,
defaultSidebarCollapsed = false
defaultSidebarCollapsed = false,
launcherMode = false,
showViewAsAdmin = false
}: LayoutProps) {
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
@@ -37,51 +47,67 @@ export async function Layout({
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
<div className="flex h-screen-safe overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
orgId={orgId}
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
hasCookiePreference={hasCookiePreference}
/>
)}
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
{/* Mobile header */}
{showHeader && (
<LayoutMobileMenu
<CommandPaletteProvider
orgId={orgId}
orgs={orgs}
navItems={commandNavItems}
>
<div className="flex h-screen-safe overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
orgId={orgId}
orgs={orgs}
navItems={navItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
defaultSidebarCollapsed={initialSidebarCollapsed}
hasCookiePreference={hasCookiePreference}
/>
)}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
{/* Mobile header */}
{showHeader && (
<LayoutMobileMenu
orgId={orgId}
orgs={orgs}
navItems={navItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
launcherMode={launcherMode}
showViewAsAdmin={showViewAsAdmin}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}
</div>
</main>
{/* Desktop header */}
{showHeader && (
<LayoutHeader
showTopBar={showTopBar}
launcherMode={launcherMode}
orgId={orgId}
orgs={orgs}
showViewAsAdmin={showViewAsAdmin}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}
</div>
</main>
</div>
</div>
</div>
</CommandPaletteProvider>
);
}
+50 -8
View File
@@ -8,16 +8,32 @@ import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { ListUserOrgsResponse } from "@server/routers/org";
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
interface LayoutHeaderProps {
type LayoutHeaderProps = {
showTopBar: boolean;
}
launcherMode?: boolean;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
showViewAsAdmin?: boolean;
};
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
export function LayoutHeader({
showTopBar,
launcherMode = false,
orgId,
orgs,
showViewAsAdmin = false
}: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
@@ -53,21 +69,47 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
<div className="flex items-center gap-5 min-w-0">
<Link
href="/"
className="flex items-center shrink-0"
>
<BrandingLogo
width={logoWidth}
height={logoHeight}
/>
</Link>
{/* {build === "saas" && (
<Badge variant="secondary">Cloud Beta</Badge>
)} */}
{launcherMode ? (
<>
<LauncherOrgSelector
orgId={orgId}
orgs={orgs}
/>
{showViewAsAdmin && orgId ? (
<Button
variant="text"
size="sm"
className="p-0"
asChild
>
<Link href={`/${orgId}/settings`}>
{t(
"resourceLauncherViewAsAdmin"
)}
</Link>
</Button>
) : null}
</>
) : null}
</div>
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<CommandPaletteTrigger
orgId={orgId}
orgs={orgs}
/>
<ProfileIcon />
</div>
)}
+137 -46
View File
@@ -1,27 +1,27 @@
"use client";
import React, { useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Menu, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import type { SidebarNavSection } from "@app/app/navigation";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import { OrgSelector } from "@app/components/OrgSelector";
import ProfileIcon from "@app/components/ProfileIcon";
import { SidebarNav } from "@app/components/SidebarNav";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Button } from "@app/components/ui/button";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetDescription,
SheetTitle,
SheetDescription
SheetTrigger
} from "@app/components/ui/sheet";
import { Abel } from "next/font/google";
import { useUserContext } from "@app/hooks/useUserContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Menu, Server, Settings, LayoutGrid } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
interface LayoutMobileMenuProps {
orgId?: string;
@@ -29,6 +29,8 @@ interface LayoutMobileMenuProps {
navItems: SidebarNavSection[];
showSidebar: boolean;
showTopBar: boolean;
launcherMode?: boolean;
showViewAsAdmin?: boolean;
}
export function LayoutMobileMenu({
@@ -36,19 +38,33 @@ export function LayoutMobileMenu({
orgs,
navItems,
showSidebar,
showTopBar
showTopBar,
launcherMode = false,
showViewAsAdmin = false
}: LayoutMobileMenuProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const t = useTranslations();
const showMobileNav = showSidebar || launcherMode;
const currentOrg = orgs?.find((org) => org.orgId === orgId);
const isSettingsPage = Boolean(
orgId && pathname?.includes(`/${orgId}/settings`)
);
const canViewResourceLauncher = Boolean(
currentOrg?.isAdmin || currentOrg?.isOwner
);
const mobileNavLinkClassName = cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
);
return (
<div className="shrink-0 md:hidden sticky top-0 z-50">
<div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4">
{showSidebar && (
{showMobileNav && (
<div>
<Sheet
open={isMobileMenuOpen}
@@ -69,24 +85,24 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
{launcherMode ? (
<>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
{showViewAsAdmin && orgId ? (
<div className="px-3">
<div className="mb-1">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
)}
href={`/${orgId}/settings`}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
@@ -94,25 +110,95 @@ export function LayoutMobileMenu({
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<Server className="h-4 w-4" />
<Settings className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"serverAdmin"
"resourceLauncherViewAsAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(false)
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
</div>
) : null}
</>
) : (
<>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div className="mb-1">
<Link
href={`/${orgId}`}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<LayoutGrid className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"resourceSidebarLauncherTitle"
)}
</span>
</Link>
</div>
)}
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">
<Link
href="/admin"
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
@@ -121,6 +207,11 @@ export function LayoutMobileMenu({
{showTopBar && (
<div className="ml-auto flex items-center justify-end">
<div className="flex items-center space-x-2">
<CommandPaletteTrigger
variant="mobile"
orgId={orgId}
orgs={orgs}
/>
<ThemeSwitcher />
<ProfileIcon />
</div>
+103 -34
View File
@@ -3,7 +3,6 @@
import type { SidebarNavSection } from "@app/app/navigation";
import { OrgSelector } from "@app/components/OrgSelector";
import { SidebarNav } from "@app/components/SidebarNav";
import SupporterStatus from "@app/components/SupporterStatus";
import {
Tooltip,
TooltipContent,
@@ -19,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org";
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
import {
ArrowRight,
ExternalLink,
LayoutGrid,
PanelRightOpen,
Server
} from "lucide-react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -131,6 +136,13 @@ export function LayoutSidebar({
const showTrial =
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
const isSettingsPage = Boolean(
orgId && pathname?.includes(`/${orgId}/settings`)
);
const canViewResourceLauncher = Boolean(
currentOrg?.isAdmin || currentOrg?.isOwner
);
return (
<div
className={cn(
@@ -153,6 +165,60 @@ export function LayoutSidebar({
/>
<div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div
className={cn(
"shrink-0",
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>
{t(
"resourceSidebarLauncherTitle"
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
</span>
<span className="flex-1">
{t("resourceSidebarLauncherTitle")}
</span>
</Link>
)}
</div>
)}
{!isAdminPage && user.serverAdmin && (
<div
className={cn(
@@ -160,36 +226,44 @@ export function LayoutSidebar({
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>{t("serverAdmin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href="/admin"
className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
!isSidebarCollapsed && "mr-3"
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
</>
)}
</Link>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t("serverAdmin")}
</span>
</Link>
)}
</div>
)}
<SidebarNav
@@ -258,11 +332,6 @@ export function LayoutSidebar({
/>
</div>
)}
{build === "oss" && (
<div className="px-4">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && (
<div className="px-4">
<SidebarSupportButton
+16 -2
View File
@@ -28,10 +28,11 @@ import {
ChevronRight,
Download,
Loader,
LoaderIcon,
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Tooltip,
TooltipContent,
@@ -427,7 +428,7 @@ export function LogDataTable<TData, TValue>({
)}
</div>
</CardHeader>
<CardContent>
<CardContent className="relative">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -535,6 +536,19 @@ export function LogDataTable<TData, TValue>({
)}
</TableBody>
</Table>
{isLoading && (
<>
<div className="backdrop-blur-[3px] z-10 absolute inset-0 top-10"></div>
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
<div className="flex items-center gap-2 p-6">
<LoaderIcon className="size-4 animate-spin" />
{t("loadingEllipsis")}
</div>
</div>
</>
)}
<div className="mt-4">
<DataTablePagination
table={table}
+1
View File
@@ -345,6 +345,7 @@ export default function LoginForm({
error={error}
loading={loading}
formId="form"
username={form.getValues("email")}
/>
)}
+1
View File
@@ -265,6 +265,7 @@ export default function LoginPasswordForm({
}}
error={error}
loading={loading}
username={identifier}
/>
);
}
+125 -56
View File
@@ -2,7 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,29 +10,39 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
CircleSlash,
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon
ChevronsUpDownIcon,
CircleSlash,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
import {
productUpdatesQueries,
type LatestVersionResponse
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
@@ -53,6 +63,11 @@ export type ClientRow = {
archived?: boolean;
blocked?: boolean;
approvalState: "approved" | "pending" | "denied";
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type ClientTableProps = {
@@ -84,17 +99,23 @@ export default function MachineClientsTable({
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [isRefreshing, startRefreshTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data;
const defaultMachineColumnVisibility = {
subnet: false,
userId: false,
niceId: false
niceId: false,
labels: true
};
const refreshData = () => {
startTransition(() => {
startRefreshTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -254,7 +275,7 @@ export default function MachineClientsTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
friendlyName: t("status"),
header: () => {
return (
<ColumnFilterButton
@@ -276,7 +297,7 @@ export default function MachineClientsTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
label={t("status")}
className="p-3"
/>
);
@@ -359,6 +380,40 @@ export default function MachineClientsTable({
cell: ({ row }) => {
const originalRow = row.original;
const agentVersionMap: Record<string, string> = {
"Pangolin Windows": "windows",
"Pangolin Android": "android",
"Pangolin iOS": "ios",
"Pangolin iPadOS": "ios",
"Pangolin macOS": "mac",
"Pangolin CLI": "cli",
"Olm CLI": "olm"
};
let updateAvailable = false;
if (
originalRow.olmVersion &&
originalRow.agent &&
latestPlatformVersions
) {
const agent = agentVersionMap[
originalRow.agent
] as keyof LatestVersionResponse;
if (agent in latestPlatformVersions) {
const agentVersion = latestPlatformVersions[agent];
updateAvailable = Boolean(
semver.valid(originalRow.olmVersion) &&
semver.lt(
originalRow.olmVersion,
agentVersion.latestVersion
)
);
}
}
return (
<div className="flex items-center space-x-1">
{originalRow.agent && originalRow.olmVersion ? (
@@ -370,9 +425,9 @@ export default function MachineClientsTable({
) : (
"-"
)}
{/*originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)*/}
{updateAvailable && (
<InfoPopup info={t("updateAvailableInfo")} />
)}
</div>
);
}
@@ -381,6 +436,27 @@ export default function MachineClientsTable({
accessorKey: "subnet",
friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span>
},
{
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell
client={row.original}
orgId={orgId}
/>
)
}
];
@@ -464,12 +540,7 @@ export default function MachineClientsTable({
}
return baseColumns;
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
function handleFilterChange(
column: string,
@@ -541,6 +612,7 @@ export default function MachineClientsTable({
rows={machineClients}
tableId="machine-clients"
searchPlaceholder={t("machinesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`)
@@ -558,36 +630,33 @@ export default function MachineClientsTable({
columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
filters={[
{
id: "status",
label: t("status") || "Status",
multiSelect: true,
displayMode: "calculated",
options: [
{
id: "active",
label: t("active") || "Active",
value: "active"
},
{
id: "archived",
label: t("archived") || "Archived",
value: "archived"
},
{
id: "blocked",
label: t("blocked") || "Blocked",
value: "blocked"
}
],
onValueChange(selectedValues: string[]) {
handleFilterChange("status", selectedValues);
},
values: searchParams.getAll("status")
}
]}
/>
</>
);
}
type MachineClientLabelCellProps = {
client: ClientRow;
orgId: string;
};
function MachineClientLabelCell({
client,
orgId
}: MachineClientLabelCellProps) {
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
serverLabels: client.labels,
orgId,
entityId: client.id,
entityIdField: "clientId"
});
return (
<LabelsTableCell
orgId={orgId}
selectedLabels={localLabels}
onToggleLabel={toggleLabel}
onClosePopover={() => startTransition(refresh)}
/>
);
}
File diff suppressed because it is too large Load Diff
+34 -61
View File
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef } from "react";
import { UseFormReturn } from "react-hook-form";
import { Button } from "@app/components/ui/button";
import {
@@ -8,17 +7,15 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "./ui/input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import * as z from "zod";
import { REGEXP_ONLY_DIGITS } from "input-otp";
const MFA_OTP_INPUT_ID = "mfa-otp-code";
type MfaInputFormProps = {
form: UseFormReturn<{ code: string }>;
@@ -27,6 +24,7 @@ type MfaInputFormProps = {
error?: string | null;
loading?: boolean;
formId?: string;
username?: string;
};
export default function MfaInputForm({
@@ -35,55 +33,10 @@ export default function MfaInputForm({
onBack,
error,
loading = false,
formId = "mfaForm"
formId = "mfaForm",
username
}: MfaInputFormProps) {
const t = useTranslations();
const otpContainerRef = useRef<HTMLDivElement>(null);
// Auto-focus MFA input when component mounts
useEffect(() => {
const focusInput = () => {
// Try using the ref first
if (otpContainerRef.current) {
const hiddenInput = otpContainerRef.current.querySelector(
"input"
) as HTMLInputElement;
if (hiddenInput) {
hiddenInput.focus();
return;
}
}
// Fallback: query the DOM
const otpContainer = document.querySelector(
'[data-slot="input-otp"]'
);
if (!otpContainer) return;
const hiddenInput = otpContainer.querySelector(
"input"
) as HTMLInputElement;
if (hiddenInput) {
hiddenInput.focus();
return;
}
// Last resort: click the first slot
const firstSlot = otpContainer.querySelector(
'[data-slot="input-otp-slot"]'
) as HTMLElement;
if (firstSlot) {
firstSlot.click();
}
};
// Use requestAnimationFrame to wait for the next paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
focusInput();
});
});
}, []);
return (
<div className="space-y-4">
@@ -99,25 +52,45 @@ export default function MfaInputForm({
className="space-y-4"
id={formId}
>
{username ? (
<input
type="text"
name="username"
autoComplete="username"
value={username}
readOnly
tabIndex={-1}
aria-hidden="true"
className="sr-only"
/>
) : null}
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel
htmlFor={MFA_OTP_INPUT_ID}
className="sr-only"
>
{t("otpAuth")}
</FormLabel>
<FormControl>
<div
ref={otpContainerRef}
className="flex justify-center"
>
<div className="flex justify-center">
<InputOTP
id={MFA_OTP_INPUT_ID}
maxLength={6}
{...field}
autoComplete="one-time-code"
inputMode="numeric"
autoFocus
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
pattern={REGEXP_ONLY_DIGITS}
onChange={(value: string) => {
field.onChange(value);
if (value.length === 6) {
form.handleSubmit(onSubmit)();
form.handleSubmit(
onSubmit
)();
}
}}
>
+9 -9
View File
@@ -389,7 +389,7 @@ export default function NewPricingLicenseForm({
{part}
{index === 0 && (
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -548,7 +548,7 @@ export default function NewPricingLicenseForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -561,7 +561,7 @@ export default function NewPricingLicenseForm({
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -601,12 +601,12 @@ export default function NewPricingLicenseForm({
"generateLicenseKeyForm.form.complianceConfirmation"
)}{" "}
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
https://pangolin.net/fcl
</a>
</div>
</FormLabel>
@@ -799,7 +799,7 @@ export default function NewPricingLicenseForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -812,7 +812,7 @@ export default function NewPricingLicenseForm({
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -852,12 +852,12 @@ export default function NewPricingLicenseForm({
"generateLicenseKeyForm.form.complianceConfirmation"
)}{" "}
<a
href="https://pangolin.net/fcl.html"
href="https://pangolin.net/fcl"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
https://pangolin.net/fcl
</a>
</div>
</FormLabel>
+33 -4
View File
@@ -125,6 +125,13 @@ function IdpImportRowIcon({
return <IdpTypeIcon type={type} variant={variant} size={20} />;
}
function isUserMemberOfIdp(
userIdpId: number | null | undefined,
idpId: number
) {
return userIdpId != null && userIdpId === idpId;
}
type Props = {
idps: IdpRow[];
orgId: string;
@@ -362,9 +369,17 @@ export default function IdpTable({ idps, orgId }: Props) {
<p>{t("idpDeleteGlobalDescription")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
buttonText={
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
? t("idpConfirmDeleteAndRemoveMeFromOrg")
: t("idpConfirmDelete")
}
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
string={
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
? t("idpConfirmDeleteAndRemoveMeFromOrg")
: selectedIdp.name
}
title={t("idpDelete")}
/>
)}
@@ -381,11 +396,25 @@ export default function IdpTable({ idps, orgId }: Props) {
<p>{t("idpUnassociateDescription")}</p>
</div>
}
buttonText={t("idpUnassociateConfirm")}
buttonText={
isUserMemberOfIdp(
user.idpId,
selectedUnassociateIdp.idpId
)
? t("idpUnassociateAndRemoveMeFromOrg")
: t("idpUnassociateConfirm")
}
onConfirm={async () =>
unassociateIdp(selectedUnassociateIdp.idpId)
}
string={selectedUnassociateIdp.name}
string={
isUserMemberOfIdp(
user.idpId,
selectedUnassociateIdp.idpId
)
? t("idpUnassociateAndRemoveMeFromOrg")
: selectedUnassociateIdp.name
}
title={t("idpUnassociateTitle")}
warningText={t("idpUnassociateWarning")}
/>
+9 -1
View File
@@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{org.org.name}</InfoSectionContent>
@@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
{org.org.subnet || t("none")}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("utilitySubnet")}
</InfoSectionTitle>
<InfoSectionContent>
{org.org.utilitySubnet || t("none")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
+134
View File
@@ -0,0 +1,134 @@
"use client";
import z from "zod";
import { Input } from "./ui/input";
import { useTranslations } from "use-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { LABEL_COLORS } from "./labels-selector";
const labelFormSchema = z.object({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData;
disabled?: boolean;
};
export function OrgLabelForm({
onSubmit,
defaultValue,
disabled = false
}: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const form = useForm({
resolver: zodResolver(labelFormSchema),
defaultValues: {
name: defaultValue?.name ?? "",
color: defaultValue?.color ?? randomColor
}
});
return (
<Form {...form}>
<form
id="org-label-form"
className="flex flex-col gap-4 px-0.5"
action={async () => {
if (await form.trigger()) {
onSubmit(form.getValues());
}
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelColorField")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>
{color
.charAt(0)
.toUpperCase() +
color.slice(1)}
</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import { ArrowRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
export type LabelRow = {
labelId: number;
name: string;
color: string;
};
type OrgLabelsTableProps = {
labels: LabelRow[];
pagination: PaginationState;
orgId: string;
rowCount: number;
};
export default function OrgLabelsTable({
labels,
orgId,
pagination,
rowCount
}: OrgLabelsTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
const t = useTranslations();
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
},
cell: ({ row }) => (
<div className="flex items-center gap-1.5 group">
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": row.original.color
}}
/>
{row.original.name}
</div>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
)
}
],
[searchParams, t]
);
function deleteLabel(label: LabelRow) {
startTransition(async () => {
await api
.delete(`/org/${orgId}/label/${label.labelId}`)
.catch((e) => {
toast({
variant: "destructive",
title: t("labelErrorDelete"),
description: formatAxiosError(e, t("labelErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
return (
<>
{selectedLabel && (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLabel(null);
}}
dialog={
<div className="space-y-2">
<p>{t("labelQuestionRemove")}</p>
<p>{t("labelMessageRemove")}</p>
</div>
}
buttonText={t("labelDeleteConfirm")}
onConfirm={async () => deleteLabel(selectedLabel)}
string={selectedLabel.name}
title={t("labelDelete")}
/>
<EditOrgLabelDialog
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
orgId={orgId}
onSuccess={() =>
startTransition(() => router.refresh())
}
label={selectedLabel}
/>
</>
)}
<CreateOrgLabelDialog
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
orgId={orgId}
onSuccess={() => startTransition(() => router.refresh())}
/>
<ControlledDataTable
columns={columns}
rows={labels}
addButtonText={t("labelAdd")}
onAdd={() => setIsCreateModalOpen(true)}
tableId="org-labels-table"
searchPlaceholder={t("labelSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
stickyRightColumn="actions"
/>
</>
);
}
+5 -18
View File
@@ -14,9 +14,10 @@ import {
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
type OrgLoginPageProps = {
loginPage: LoadLoginPageResponse | undefined;
@@ -52,22 +53,8 @@ export default async function OrgLoginPage({
const env = pullEnv();
const t = await getTranslations();
return (
<div>
{build !== "enterprise" || !env.branding.hidePoweredBy ? (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
) : null}
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
<PoweredByPangolin />
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (
@@ -127,6 +114,6 @@ export default async function OrgLoginPage({
{t("loginBack")}
</Link>
</p>
</div>
</BrandedAuthSurface>
);
}
+72 -26
View File
@@ -12,6 +12,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { useRef } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector";
@@ -41,6 +42,46 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
disabled
}: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations();
const isPopoverOpenRef = useRef(false);
const lastValidRolesRef = useRef<SelectedRole[]>(
(form.getValues(name) as SelectedRole[]) ?? []
);
function validateRolesSelection() {
const current = form.getValues(name) as SelectedRole[];
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
form.setValue(name, lastValidRolesRef.current as never, {
shouldDirty: true
});
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return false;
}
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return true;
}
function handlePopoverOpenChange(open: boolean) {
isPopoverOpenRef.current = open;
if (open) {
const current = form.getValues(name) as SelectedRole[];
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return;
}
validateRolesSelection();
}
function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as SelectedRole[];
@@ -61,39 +102,44 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue(name, next as never, { shouldDirty: true });
if (next.length > 0 && !isPopoverOpenRef.current) {
lastValidRolesRef.current = next;
} else if (!isPopoverOpenRef.current) {
validateRolesSelection();
}
}
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl>
<RolesSelector
orgId={orgId}
selectedRoles={field.value ?? []}
onSelectRoles={setRoleTags}
disabled={disabled}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedRoles = (field.value ?? []) as SelectedRole[];
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
lastValidRolesRef.current = selectedRoles;
}
return (
<FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl>
<RolesSelector
orgId={orgId}
selectedRoles={selectedRoles}
onSelectRoles={setRoleTags}
onPopoverOpenChange={handlePopoverOpenChange}
disabled={disabled}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}}
/>
);
}
+14
View File
@@ -111,6 +111,20 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateResourceRule")]: "updateResourceRule"
},
"Resource Policy": {
[t("actionGetResourcePolicy")]: "getResourcePolicy",
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
[t("actionSetResourcePolicyPincode")]: "setResourcePolicyPincode",
[t("actionSetResourcePolicyHeaderAuth")]:
"setResourcePolicyHeaderAuth",
[t("actionSetResourcePolicyWhitelist")]:
"setResourcePolicyWhitelist",
[t("actionSetResourcePolicyRules")]: "setResourcePolicyRules"
},
Client: {
[t("actionCreateClient")]: "createClient",
[t("actionDeleteClient")]: "deleteClient",
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
function PoweredByLabel({ brandName }: { brandName: string }) {
const t = useTranslations();
return (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
{brandName === "Pangolin" ? (
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
) : (
brandName
)}
</span>
</div>
);
}
export default function PoweredByPangolin() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
if (isUnlocked() && build === "enterprise") {
if (
env.branding.resourceAuthPage?.hidePoweredBy ||
env.branding.hidePoweredBy
) {
return null;
}
return (
<PoweredByLabel
brandName={env.branding.appName || "Pangolin"}
/>
);
}
return <PoweredByLabel brandName="Pangolin" />;
}
+645
View File
@@ -0,0 +1,645 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type InternalResourceSiteRow = ResourceSiteRow;
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
sites: InternalResourceSiteRow[];
siteNames: string[];
siteAddresses: (string | null)[];
siteIds: number[];
siteNiceIds: string[];
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http" | "ssh";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null;
// proxyPort: number | null;
destination: string | null;
destinationPort: number | null;
alias: string | null;
aliasAddress: string | null;
niceId: string;
enabled: boolean;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
function formatDestinationDisplay(row: InternalResourceRow): string {
return formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
destinationPort: row.destinationPort,
scheme: row.scheme
});
}
function isSafeUrlForLink(href: string): boolean {
try {
void new URL(href);
return true;
} catch {
return false;
}
}
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
};
export default function PrivateResourcesTable({
internalResources,
orgId,
pagination,
rowCount
}: ClientResourcesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
const refreshData = () => {
startRefreshTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
startTransition(async () => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
} catch (e) {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
}
};
const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => {
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSiteId =
!siteIdQ ||
!Number.isInteger(siteIdNum) ||
siteIdNum <= 0
? null
: siteIdNum;
return (
<SitesColumnFilterButton
selectedSiteId={selectedSiteId}
onValueChange={(value) =>
handleFilterChange("siteId", value?.toString())
}
orgId={orgId}
/>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
},
{
value: "ssh",
label: t("editInternalResourceDialogModeSsh")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) =>
handleFilterChange("mode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http" | "ssh",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">
{t("resourcesTableDestination")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
if (resourceRow.destination) {
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
/>
);
}
return <span>-</span>;
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.alias) {
return (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return cols;
}, [orgId, t, searchParams]);
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteIds[0]
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<ControlledDataTable
columns={internalColumns}
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/resources/private/create`
)
)
}
addButtonText={t("resourceAdd")}
isNavigatingToAddPage={isNavigatingToAddPage}
onSearch={handleSearchChange}
onRefresh={refreshData}
onPaginationChange={handlePaginationChange}
pagination={pagination}
rowCount={rowCount}
isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility
columnVisibility={{
niceId: false,
aliasAddress: false,
labels: true
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
serverLabels: resource.labels,
orgId,
entityId: resource.id,
entityIdField: "siteResourceId"
});
return (
<LabelsTableCell
orgId={orgId}
onClosePopover={() => startTransition(refresh)}
onToggleLabel={toggleLabel}
selectedLabels={localLabels}
/>
);
}
+57 -26
View File
@@ -8,6 +8,7 @@ import {
type ProductUpdate,
productUpdatesQueries
} from "@app/lib/queries";
import { build } from "@server/build";
import { useQueries } from "@tanstack/react-query";
import {
ArrowRight,
@@ -39,22 +40,42 @@ export default function ProductUpdates({
}) {
const { env } = useEnvContext();
const productUpdatesEnabled = env.app.notifications.product_updates;
const versionCheckEnabled =
env.app.notifications.new_releases && build !== "saas";
const data = useQueries({
queries: [
productUpdatesQueries.list(
env.app.notifications.product_updates,
env.app.version
),
productUpdatesQueries.list(productUpdatesEnabled, env.app.version),
productUpdatesQueries.latestVersion(
env.app.notifications.new_releases
)
],
combine(result) {
if (result[0].isLoading || result[1].isLoading) return null;
return {
updates: result[0].data?.data ?? [],
latestVersion: result[1].data
};
const [updatesQuery, versionQuery] = result;
const updatesSettled =
!productUpdatesEnabled ||
updatesQuery.isFetched ||
updatesQuery.isError;
const versionSettled =
!versionCheckEnabled ||
versionQuery.isFetched ||
versionQuery.isError;
if (!updatesSettled || !versionSettled) return null;
const updates = updatesQuery.isError
? []
: Array.isArray(updatesQuery.data?.data)
? updatesQuery.data.data
: [];
const latestVersion = versionQuery.isError
? undefined
: versionQuery.data;
return { updates, latestVersion };
}
});
const t = useTranslations();
@@ -76,19 +97,30 @@ export default function ProductUpdates({
if (!data) return null;
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion;
const versionResponse = data.latestVersion?.data;
const latestVersion = versionResponse?.pangolin?.latestVersion;
const currentVersion = env.app.version;
const showNewVersionPopup = Boolean(
let showNewVersionPopup = false;
if (
latestVersion &&
valid(latestVersion) &&
valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion &&
gt(latestVersion, currentVersion)
);
valid(latestVersion) &&
valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion
) {
try {
showNewVersionPopup = gt(latestVersion, currentVersion);
} catch {
showNewVersionPopup = false;
}
}
const readUpdateIds = Array.isArray(productUpdatesRead)
? productUpdatesRead
: [];
const filteredUpdates = data.updates.filter(
(update) => !productUpdatesRead.includes(update.id)
(update) => !readUpdateIds.includes(update.id)
);
if (filteredUpdates.length === 0 && !showNewVersionPopup) {
@@ -133,17 +165,14 @@ export default function ProductUpdates({
show={filteredUpdates.length > 0}
onDimissAll={() =>
setProductUpdatesRead([
...productUpdatesRead,
...readUpdateIds,
...filteredUpdates.map(
(update) => update.id
)
])
}
onDimiss={(id) =>
setProductUpdatesRead([
...productUpdatesRead,
id
])
setProductUpdatesRead([...readUpdateIds, id])
}
/>
</div>
@@ -151,11 +180,9 @@ export default function ProductUpdates({
</div>
<NewVersionAvailable
version={data.latestVersion?.data}
version={versionResponse}
onDimiss={() => {
setIgnoredVersionUpdate(
data.latestVersion?.data?.pangolin.latestVersion ?? null
);
setIgnoredVersionUpdate(latestVersion ?? null);
}}
show={showNewVersionPopup}
/>
@@ -346,6 +373,10 @@ function NewVersionAvailable({
}
}, [show]);
if (!version?.pangolin?.latestVersion) {
return null;
}
return (
<Transition show={open}>
{version && (
-2
View File
@@ -19,7 +19,6 @@ import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { build } from "@server/build";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
@@ -27,7 +26,6 @@ import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import ViewDevicesDialog from "./ViewDevicesDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher";
import { useTranslations } from "next-intl";
-849
View File
@@ -1,849 +0,0 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
CheckCircle2,
ChevronDown,
ChevronsUpDownIcon,
Clock,
Funnel,
MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table";
import UptimeMiniBar from "./UptimeMiniBar";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
fullDomain?: string | null;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[];
wildcard?: boolean;
};
function StatusIcon({
status,
className = ""
}: {
status: string | undefined | null;
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "healthy":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "unhealthy":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
}
type ProxyResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterSite?: Selectedsite | null;
};
export default function ProxyResourcesTable({
resources,
orgId,
pagination,
rowCount,
initialFilterSite = null
}: ProxyResourcesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
try {
await api.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
function TargetStatusCell({
targets,
healthStatus
}: {
targets?: TargetHealth[];
healthStatus?: string;
}) {
const overallStatus = healthStatus;
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "healthy" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "unhealthy" &&
t("resourcesTableUnhealthy")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
resourceSites={row.original.sites}
/>
)
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
enableHiding: true,
header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
id: "status",
accessorKey: "status",
friendlyName: t("health"),
header: () => (
<ColumnFilterButton
options={[
{ value: "healthy", label: t("resourcesTableHealthy") },
{
value: "degraded",
label: t("resourcesTableDegraded")
},
{
value: "unhealthy",
label: t("resourcesTableUnhealthy")
},
{ value: "unknown", label: t("resourcesTableUnknown") }
]}
selectedValue={
searchParams.get("healthStatus") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("healthStatus", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("health")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<TargetStatusCell
targets={resourceRow.targets}
healthStatus={resourceRow.health}
/>
);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.health;
const statusB = rowB.original.health;
if (!statusA && !statusB) return 0;
if (!statusA) return 1;
if (!statusB) return -1;
const statusOrder = {
healthy: 3,
degraded: 2,
unhealthy: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{
id: "statusHistory",
friendlyName: t("uptime30d"),
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return <UptimeMiniBar resourceId={resourceRow.id} days={30} />;
}
},
{
accessorKey: "domain",
friendlyName: t("access"),
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
if (!resourceRow.http) {
return (
<div className="flex items-center gap-2 min-w-0">
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
</div>
);
}
if (!resourceRow.domainId) {
return (
<div className="flex items-center gap-2 min-w-0">
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
</div>
);
}
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{showHttpsCertIndicator ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={certHostname}
/>
) : null}
<div className="">
{!resourceRow.wildcard ? (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
) : (
<span>{resourceRow.domain}</span>
)}
</div>
</div>
);
}
},
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: () => (
<ColumnFilterButton
options={[
{ value: "protected", label: t("protected") },
{ value: "not_protected", label: t("notProtected") },
{ value: "none", label: t("none") }
]}
selectedValue={searchParams.get("authState") ?? undefined}
onValueChange={(value) =>
handleFilterChange("authState", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("authentication")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<ResourceEnabledForm
resource={row.original}
onToggleResourceEnabled={toggleResourceEnabled}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (value) {
searchParams.set(column, value);
}
filter({
searchParams
});
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
<ControlledDataTable
columns={proxyColumns}
rows={resources}
tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/resources/proxy/create`)
)
}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={{ niceId: false, protocol: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
type ResourceEnabledFormProps = {
resource: ResourceRow;
onToggleResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function ResourceEnabledForm({
resource,
onToggleResourceEnabled
}: ResourceEnabledFormProps) {
const enabled = resource.http
? !!resource.domainId && resource.enabled
: resource.enabled;
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={
(resource.http && !resource.domainId) ||
optimisticEnabled !== enabled
}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
@@ -1,22 +1,21 @@
"use client";
import React from "react";
import { Globe } from "lucide-react";
import { useTranslations } from "next-intl";
import DismissableBanner from "./DismissableBanner";
export const ProxyResourcesBanner = () => {
export const PublicResourcesBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="proxy-resources-banner-dismissed"
version={1}
title={t("proxyResourcesBannerTitle")}
title={t("publicResourcesBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("proxyResourcesBannerDescription")}
description={t("publicResourcesBannerDescription")}
/>
);
};
export default ProxyResourcesBanner;
export default PublicResourcesBanner;
+930
View File
@@ -0,0 +1,930 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import {
ResourceSitesStatusCell,
type ResourceSiteRow
} from "@app/components/ResourceSitesStatusCell";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
CheckCircle2,
ChevronDown,
ChevronsUpDownIcon,
Clock,
Funnel,
MoreHorizontal,
PlusIcon,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table";
import UptimeMiniBar from "./UptimeMiniBar";
import { type SelectedLabel } from "./labels-selector";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { LabelsTableCell } from "./LabelsTableCell";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { refresh } from "next/cache";
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
mode: string | null;
authState: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
/** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */
fullDomain?: string | null;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[];
wildcard?: boolean;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type ProxyResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialFilterSite?: Selectedsite | null;
};
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
export default function PublicResourcesTable({
resources,
orgId,
pagination,
rowCount,
initialFilterSite = null
}: ProxyResourcesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const { isPaidUser } = usePaidStatus();
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteResource = async (resourceId: number) => {
await api.delete(`/resource/${resourceId}`).catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
});
router.refresh();
setIsDeleteModalOpen(false);
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
try {
await api.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
const proxyColumns = useMemo<ExtendedColumnDef<ResourceRow>[]>(() => {
const cols: ExtendedColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => {
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSiteId =
!siteIdQ ||
!Number.isInteger(siteIdNum) ||
siteIdNum <= 0
? null
: siteIdNum;
return (
<SitesColumnFilterButton
selectedSiteId={selectedSiteId}
onValueChange={(value) =>
handleFilterChange("siteId", value?.toString())
}
orgId={orgId}
/>
);
},
cell: ({ row }) => (
<ResourceSitesStatusCell
orgId={row.original.orgId}
resourceSites={row.original.sites}
/>
)
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
enableHiding: true,
header: () => (
<ColumnFilterButton
options={[
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
},
{
value: "https",
label: t("editInternalResourceDialogModeHttps")
},
{
value: "tcp",
label: t("editInternalResourceDialogTcp")
},
{
value: "udp",
label: t("editInternalResourceDialogUdp")
},
{
value: "ssh",
label: t("editInternalResourceDialogModeSsh")
},
{
value: "rdp",
label: t("rdpTitle")
},
{
value: "vnc",
label: t("vncTitle")
}
]}
selectedValue={
searchParams.get("protocol") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("protocol", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("protocol")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>
{resourceRow.mode == "http"
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.mode?.toUpperCase()}
</span>
);
}
},
{
id: "status",
accessorKey: "status",
friendlyName: t("health"),
header: () => (
<ColumnFilterButton
options={[
{
value: "healthy",
label: t("resourcesTableHealthy")
},
{
value: "degraded",
label: t("resourcesTableDegraded")
},
{
value: "unhealthy",
label: t("resourcesTableUnhealthy")
},
{
value: "unknown",
label: t("resourcesTableUnknown")
}
]}
selectedValue={
searchParams.get("healthStatus") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("healthStatus", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("health")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode !== "http") {
return <span>-</span>;
}
return (
<TargetStatusCell
targets={resourceRow.targets}
healthStatus={resourceRow.health}
/>
);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.health;
const statusB = rowB.original.health;
if (!statusA && !statusB) return 0;
if (!statusA) return 1;
if (!statusB) return -1;
const statusOrder = {
healthy: 3,
degraded: 2,
unhealthy: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{
id: "statusHistory",
friendlyName: t("uptime30d"),
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode !== "http") {
return <span>-</span>;
}
return (
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
);
}
},
{
accessorKey: "domain",
friendlyName: t("access"),
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
if (
!["http", "ssh", "rdp", "vnc"].includes(
resourceRow.mode || ""
)
) {
return (
<div className="flex items-center gap-2 min-w-0">
<CopyToClipboard
text={
resourceRow.proxyPort?.toString() || ""
}
isLink={false}
/>
</div>
);
}
if (!resourceRow.domainId) {
return (
<div className="flex items-center gap-2 min-w-0">
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
</div>
);
}
const domainId = resourceRow.domainId;
const certHostname = resourceRow.fullDomain;
const showHttpsCertIndicator =
build !== "oss" &&
resourceRow.ssl &&
certHostname != null &&
certHostname !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{showHttpsCertIndicator ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={certHostname}
/>
) : null}
<div className="">
{!resourceRow.wildcard ? (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
) : (
<span>{resourceRow.domain}</span>
)}
</div>
</div>
);
}
},
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: () => (
<ColumnFilterButton
options={[
{ value: "protected", label: t("protected") },
{
value: "not_protected",
label: t("notProtected")
},
{ value: "none", label: t("none") }
]}
selectedValue={
searchParams.get("authState") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("authState", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("authentication")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<ResourceEnabledForm
resource={row.original}
onToggleResourceEnabled={toggleResourceEnabled}
/>
)
},
{
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ResourceRow } }) => (
<ResourceLabelCell resource={row.original} orgId={orgId} />
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/public/${resourceRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/public/${resourceRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return cols;
}, [orgId, t, searchParams]);
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
startTransition(() =>
deleteResource(selectedResource!.id)
)
}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
<ControlledDataTable
columns={proxyColumns}
rows={resources}
tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
pagination={pagination}
rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/resources/public/create`
)
)
}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={{
niceId: false,
protocol: false,
labels: true
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
type ResourceLabelCellProps = {
resource: ResourceRow;
orgId: string;
};
function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) {
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
serverLabels: resource.labels,
orgId,
entityId: resource.id,
entityIdField: "resourceId"
});
return (
<LabelsTableCell
orgId={orgId}
selectedLabels={localLabels}
onToggleLabel={toggleLabel}
onClosePopover={() => startTransition(refresh)}
/>
);
}
function TargetStatusCell({
targets,
healthStatus
}: {
targets?: TargetHealth[];
healthStatus?: string;
}) {
const overallStatus = healthStatus;
const t = useTranslations();
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2 px-0">
<StatusIcon status="unknown" />
<span className="text-sm">{t("resourcesTableNoTargets")}</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "healthy" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "unhealthy" &&
t("resourcesTableUnhealthy")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus === "healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
type ResourceEnabledFormProps = {
resource: ResourceRow;
onToggleResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function ResourceEnabledForm({
resource,
onToggleResourceEnabled
}: ResourceEnabledFormProps) {
const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "")
? !!resource.domainId && resource.enabled
: resource.enabled;
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={
(["http", "ssh", "rdp", "vnc"].includes(
resource.mode || ""
) &&
!resource.domainId) ||
optimisticEnabled !== enabled
}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
function StatusIcon({
status,
className = ""
}: {
status: string | undefined | null;
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "healthy":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "unhealthy":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
}
+13 -4
View File
@@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
type RedirectToOrgProps = {
targetOrgId: string;
isAdminOrOwner?: boolean;
};
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
export default function RedirectToOrg({
targetOrgId,
isAdminOrOwner = false
}: RedirectToOrgProps) {
const router = useRouter();
useEffect(() => {
try {
const target =
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
getInternalRedirectTarget(targetOrgId) ??
(isAdminOrOwner
? `/${targetOrgId}/settings`
: `/${targetOrgId}`);
router.replace(target);
} catch {
router.replace(`/${targetOrgId}`);
router.replace(
isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}`
);
}
}, [targetOrgId, router]);
}, [targetOrgId, isAdminOrOwner, router]);
return null;
}
+10 -69
View File
@@ -41,9 +41,10 @@ import {
} from "@app/actions/server";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
@@ -123,8 +124,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const { env } = useEnvContext();
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() {
if (props.methods.sso) {
return "sso";
@@ -301,6 +300,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
let isAllowed = false;
try {
const response = await resourceAccessProxy(props.resource.id);
console.log("response", response);
if (response.error) {
setAccessDenied(true);
} else {
@@ -366,57 +366,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
: 100;
return (
<div
style={{
// @ts-expect-error CSS variable
"--primary": isUnlocked() ? props.branding?.primaryColor : null
}}
>
<BrandedAuthSurface primaryColor={props.branding?.primaryColor}>
{!accessDenied ? (
<div>
{isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy &&
!env.branding.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
)
) : (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
)}
<PoweredByPangolin />
<Card>
<CardHeader>
{isUnlocked() &&
build !== "oss" &&
(env.branding?.resourceAuthPage?.showLogo ||
props.branding) && (
props.branding?.logoUrl && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={logoHeight}
width={logoWidth}
logoPath={props.branding?.logoUrl}
logoPath={props.branding.logoUrl}
/>
</div>
)}
@@ -763,33 +726,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs>
</CardContent>
</Card>
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t("noSupportKey")}
</span>
</div>
)}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("instanceIsUnlicensed")}
</span>
</div>
) : null}
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("loginPageLicenseWatermark")}
</span>
</div>
) : null}
<AuthPageFooterNotices />
</div>
) : (
<ResourceAccessDenied />
)}
</div>
</BrandedAuthSurface>
);
}
+105 -91
View File
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
ShieldCheck,
ShieldOff,
Eye,
EyeOff,
CheckCircle2,
XCircle,
Clock
XCircle
} from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
@@ -32,20 +30,43 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
const showCertificate = !!(
["http", "ssh", "rdp", "vnc"].includes(resource.mode) &&
resource.domainId &&
resource.fullDomain &&
build != "oss"
);
const showType = !!(
["http", "ssh", "rdp", "vnc"].includes(resource.mode) && resource.mode
);
const showHealth =
!["ssh", "rdp", "vnc"].includes(resource.mode || "") &&
!!resource.health &&
resource.health !== "unknown";
const showVisibility = !resource.enabled;
const numSections = [
true, // URL or Protocol
true, // Authentication or Port
showType,
showCertificate,
showHealth,
showVisibility
].filter(Boolean).length;
return (
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection>
<InfoSections cols={numSections}>
{/* <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.niceId}
</span>
</InfoSectionContent>
</InfoSection>
{resource.http ? (
</InfoSection> */}
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? (
<>
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
@@ -62,6 +83,22 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)}
</InfoSectionContent>
</InfoSection>
{showType && (
<InfoSection>
<InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.mode == "http"
? resource.ssl
? "HTTPS"
: "HTTP"
: resource.mode?.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
@@ -84,24 +121,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)}
</InfoSectionContent>
</InfoSection>
{/* {isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)} */}
</>
) : (
<>
@@ -111,7 +130,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.protocol.toUpperCase()}
{resource.mode?.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
@@ -149,74 +168,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* </InfoSectionContent> */}
{/* </InfoSection> */}
{/* Certificate Status Column */}
{resource.http &&
resource.domainId &&
resource.fullDomain &&
build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={resource.fullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
{resource.health === "healthy" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
)}
{resource.health === "degraded" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
)}
{resource.health === "unhealthy" && (
<div className="flex items-center space-x-2">
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
)}
{(!resource.health ||
resource.health === "unknown") && (
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 flex-shrink-0" />
<span>{t("resourcesTableUnknown")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
{resource.enabled ? (
<div className="flex items-center space-x-2">
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("enabled")}</span>
</div>
) : (
{showCertificate && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId!}
fullDomain={resource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
{showHealth && (
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
{resource.health === "healthy" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>
{t("resourcesTableHealthy")}
</span>
</div>
)}
{resource.health === "degraded" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>
{t("resourcesTableDegraded")}
</span>
</div>
)}
{resource.health === "unhealthy" && (
<div className="flex items-center space-x-2">
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
<span>
{t("resourcesTableUnhealthy")}
</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
)}
{showVisibility && (
<InfoSection>
<InfoSectionTitle>
{t("visibility")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center space-x-2">
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
<span>{t("disabled")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Shield, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
export const ResourcePoliciesBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="resource-policies-banner-dismissed"
version={1}
title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/resources/public/resource-policies"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("resourcePoliciesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default ResourcePoliciesBanner;
+307
View File
@@ -0,0 +1,307 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type {
AttachedResource,
ListResourcePoliciesResponse
} from "@server/routers/resource/types";
import type { PaginationState } from "@tanstack/react-table";
import { ArrowRight, ChevronDown, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Button } from "./ui/button";
import { ControlledDataTable } from "./ui/controlled-data-table";
import type { ExtendedColumnDef } from "./ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "./ui/dropdown-menu";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
export type ResourcePoliciesTableProps = {
policies: Array<ResourcePolicyRow>;
orgId: string;
pagination: PaginationState;
rowCount: number;
};
export function ResourcePoliciesTable({
policies,
orgId,
pagination,
rowCount
}: ResourcePoliciesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResourcePolicy, setSelectedResourcePolicy] =
useState<ResourcePolicyRow | null>(null);
const deleteResourcePolicy = async (resourcePolicyId: number) => {
await api
.delete(`/resource-policy/${resourcePolicyId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
function ResourceListCell({
orgId,
resources
}: {
orgId: string;
resources?: AttachedResource[];
}) {
if (!resources || resources.length === 0) {
return <span>-</span>;
}
const countLabel = t("resourcePoliciesAttachedResourcesCount", {
count: resources.length
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<span className="text-sm tabular-nums">
{countLabel}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
{resources.map((resource) => (
<DropdownMenuItem key={resource.resourceId} asChild>
<Link
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
className="flex cursor-pointer items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">
{resource.name}
</span>
</div>
<span className="shrink-0 text-muted-foreground">
{resource.fullDomain}
</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => <span>{row.original.name}</span>
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "resources",
accessorKey: "resources",
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
header: () => (
<span className="p-3">
{t("resourcePoliciesAttachedResourcesColumnTitle")}
</span>
),
cell: ({ row }) => {
return (
<ResourceListCell
orgId={row.original.orgId}
resources={row.original.resources}
/>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const policyRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResourcePolicy(policyRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.ResourcePolicies]}
/>
{selectedResourcePolicy && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResourcePolicy(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourcePolicyQuestionRemove")}</p>
<p>{t("resourcePolicyMessageRemove")}</p>
</div>
}
buttonText={t("resourcePolicyDeleteConfirm")}
onConfirm={async () =>
deleteResourcePolicy(
selectedResourcePolicy.resourcePolicyId
)
}
string={selectedResourcePolicy.name}
title={t("resourcePolicyDelete")}
/>
)}
<ControlledDataTable
columns={proxyColumns}
rows={policies}
tableId="resource-policies"
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/policies/resources/public/create`
)
)
}
addButtonText={t("resourcePoliciesAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={{ niceId: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
+232 -25
View File
@@ -10,14 +10,25 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { TextFileImportDialog } from "@app/components/TextFileImportDialog";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { cn } from "@app/lib/cn";
import {
getTextImportFileType,
isSupportedTextImportFile,
parseTextFileItems,
readFileAsText,
type TextImportFileType
} from "@app/lib/roleFormTextImport";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -46,6 +57,31 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
return "none";
}
export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return [];
return value
.split(/\r?\n/)
.map((group) => group.trim())
.filter(Boolean);
}
export function parseSudoCommands(value: string | undefined): string[] {
if (!value?.trim()) return [];
return value
.split(/\r?\n/)
.map((command) => command.trim())
.filter(Boolean);
}
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
return parseSudoCommands(value).every((command) => {
const executable = command.split(/\s+/)[0];
return executable.startsWith("/");
});
}
export type RoleFormValues = {
name: string;
description?: string;
@@ -64,6 +100,15 @@ type RoleFormProps = {
formId?: string;
};
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
type PendingTextImport = {
field: RoleTextImportField;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
};
export function RoleForm({
variant,
role,
@@ -74,19 +119,33 @@ export function RoleForm({
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: z.string().optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.string().optional()
});
const formSchema = z
.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: z.string().optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.string().optional()
})
.superRefine((values, ctx) => {
if (
values.sshSudoMode === "commands" &&
!hasOnlyAbsoluteSudoCommands(values.sshSudoCommands)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sshSudoCommands"],
message:
"Each sudo command must start with an absolute path (for example, /usr/bin/systemctl)."
});
}
});
const defaultValues: RoleFormValues = role
? {
@@ -97,10 +156,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
"\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
}
: {
name: "",
@@ -128,17 +187,21 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
"\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
});
}
}, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
const [pendingImport, setPendingImport] =
useState<PendingTextImport | null>(null);
const [dragOverField, setDragOverField] =
useState<RoleTextImportField | null>(null);
useEffect(() => {
if (sshDisabled) {
@@ -146,6 +209,78 @@ export function RoleForm({
}
}, [sshDisabled, form]);
async function handleFileDrop(
file: File,
field: RoleTextImportField
): Promise<void> {
if (!isSupportedTextImportFile(file)) {
toast({
variant: "destructive",
title: t("roleTextImportInvalidFile"),
description: t("roleTextImportInvalidFileDescription")
});
return;
}
const fileType = getTextImportFileType(file);
if (!fileType) return;
const rawContent = await readFileAsText(file);
const parser =
field === "sshSudoCommands" ? parseSudoCommands : parseUnixGroups;
const items = parseTextFileItems({
content: rawContent,
fileType,
skipHeader: false,
parser
});
if (items.length === 0) {
toast({
variant: "destructive",
title: t("roleTextImportEmpty"),
description: t("roleTextImportEmptyDescription")
});
return;
}
setPendingImport({
field,
fileName: file.name,
fileType,
rawContent
});
}
function getTextImportDropHandlers(field: RoleTextImportField) {
return {
onDragOver: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
if (!sshDisabled) {
setDragOverField(field);
}
},
onDragLeave: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setDragOverField((current) =>
current === field ? null : current
);
},
onDrop: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
setDragOverField(null);
if (sshDisabled) return;
const file = event.dataTransfer.files[0];
if (file) {
void handleFileDrop(file, field);
}
}
};
}
return (
<Form {...form}>
<form
@@ -291,12 +426,16 @@ export function RoleForm({
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<FormField
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
const allowSshOptions: OptionSelectOption<
"allow" | "disallow"
>[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
@@ -311,7 +450,9 @@ export function RoleForm({
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<"allow" | "disallow">
<OptionSelect<
"allow" | "disallow"
>
options={allowSshOptions}
value={
sshDisabled
@@ -322,7 +463,9 @@ export function RoleForm({
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(v === "allow");
field.onChange(
v === "allow"
);
}}
cols={2}
disabled={sshDisabled}
@@ -385,9 +528,25 @@ export function RoleForm({
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Input
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -410,9 +569,25 @@ export function RoleForm({
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Input
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -463,6 +638,38 @@ export function RoleForm({
</HorizontalTabs>
)}
</form>
{pendingImport && (
<TextFileImportDialog
key={`${pendingImport.field}-${pendingImport.fileName}`}
open={true}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.fileName}
fileType={pendingImport.fileType}
rawContent={pendingImport.rawContent}
currentValue={form.watch(pendingImport.field) ?? ""}
fieldLabel={
pendingImport.field === "sshSudoCommands"
? t("sshSudoCommands")
: t("sshUnixGroups")
}
parser={
pendingImport.field === "sshSudoCommands"
? parseSudoCommands
: parseUnixGroups
}
onConfirm={(value) => {
form.setValue(pendingImport.field, value, {
shouldDirty: true,
shouldValidate: true
});
setPendingImport(null);
}}
/>
)}
</Form>
);
}
+1 -1
View File
@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
label={t(
"headerAuthCompatibility"
)}
info={t(
description={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
+98 -3
View File
@@ -21,6 +21,29 @@ export function SettingsSectionHeader({
}
export function SettingsSectionForm({
children,
className,
variant = "compact"
}: {
children: React.ReactNode;
variant?: "half" | "compact";
className?: string;
}) {
return (
<div
className={cn(
variant === "half"
? "max-w-3xl space-y-4"
: "max-w-xl space-y-4",
className
)}
>
{children}
</div>
);
}
export function SettingsFormGrid({
children,
className
}: {
@@ -28,7 +51,38 @@ export function SettingsSectionForm({
className?: string;
}) {
return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
<div
className={cn(
"grid grid-cols-1 md:grid-cols-4 gap-4 items-start",
className
)}
>
{children}
</div>
);
}
export function SettingsFormCell({
children,
span = "half",
className
}: {
children: React.ReactNode;
span?: "quarter" | "half" | "full";
className?: string;
}) {
return (
<div
className={cn(
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "md:col-span-4",
className
)}
>
{children}
</div>
);
}
@@ -52,6 +106,40 @@ export function SettingsSectionDescription({
return <p className="text-muted-foreground text-sm">{children}</p>;
}
export function SettingsSubsectionHeader({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("py-3 space-y-0.5", className)}>{children}</div>;
}
export function SettingsSubsectionTitle({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <h3 className={cn("font-semibold", className)}>{children}</h3>;
}
export function SettingsSubsectionDescription({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
);
}
export function SettingsSectionBody({
children
}: {
@@ -61,12 +149,19 @@ export function SettingsSectionBody({
}
export function SettingsSectionFooter({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
<div
className={cn(
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
className
)}
>
{children}
</div>
);
+2 -4
View File
@@ -145,7 +145,7 @@ export default function ShareLinksTable({
const r = row.original;
return (
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
>
<Button variant="outline" size="sm">
{r.resourceName}
@@ -328,9 +328,7 @@ export default function ShareLinksTable({
onConfirm={async () =>
deleteSharelink(selectedLink.accessTokenId)
}
string={
selectedLink.title || selectedLink.resourceName
}
string={selectedLink.title || selectedLink.resourceName}
title={t("shareDelete")}
/>
)}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import * as React from "react";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import {
Sheet,
SheetClose,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger
} from "./ui/sheet";
import * as SheetPrimitive from "@radix-ui/react-dialog";
type BaseProps = {
children: React.ReactNode;
};
type RootSidePanelProps = BaseProps & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
type SidePanelProps = {
className?: string;
asChild?: true;
children?: React.ReactNode;
};
const desktop = "(min-width: 768px)";
const SidePanel = ({ children, ...props }: RootSidePanelProps) => {
return <Sheet {...props}>{children}</Sheet>;
};
const SidePanelTrigger = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetTrigger className={className} {...props}>
{children}
</SheetTrigger>
);
};
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetClose className={className} {...props}>
{children}
</SheetClose>
);
};
const SidePanelContent = ({
className,
children,
...props
}: SidePanelProps) => {
const isDesktop = useMediaQuery(desktop);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
isDesktop
? "inset-y-0 right-0 h-full w-2/5 border-l"
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
className
)}
{...props}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{children}
</SheetPrimitive.Content>
</SheetPortal>
);
};
const SidePanelDescription = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetDescription className={className} {...props}>
{children}
</SheetDescription>
);
};
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetHeader
className={cn("shrink-0 -mx-6 px-6", className)}
{...props}
>
{children}
</SheetHeader>
);
};
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetTitle className={className} {...props}>
{children}
</SheetTitle>
);
};
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
return (
<div
className={cn(
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
className
)}
{...props}
>
<div className="space-y-4">{children}</div>
<div
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
aria-hidden
/>
</div>
);
};
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetFooter
className={cn(
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
className
)}
{...props}
>
{children}
</SheetFooter>
);
};
export {
SidePanel,
SidePanelBody,
SidePanelClose,
SidePanelContent,
SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle,
SidePanelTrigger
};
+2 -2
View File
@@ -554,7 +554,7 @@ export default function SignupForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
href="https://pangolin.net/tos"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -567,7 +567,7 @@ export default function SignupForm({
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
href="https://pangolin.net/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
+7 -3
View File
@@ -9,6 +9,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
type SiteInfoCardProps = {};
@@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection>
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicEndpoint(site.endpoint)}
{formatPublicEndpoint(site.endpoint)}&nbsp;
<span>
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
</span>
</InfoSectionContent>
</InfoSection>
) : null;
@@ -61,8 +66,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={site.endpoint ? 5 : 4}>
{identifierSection}
<InfoSections cols={site.endpoint ? 4 : 3}>
{statusSection}
<InfoSection>
<InfoSectionTitle>
+267
View File
@@ -0,0 +1,267 @@
"use client";
import CertificateStatus from "@app/components/CertificateStatus";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails";
import {
formatSiteResourceAccess,
formatSiteResourceDestinationDisplay,
isSafeUrlForLink,
type LauncherAccessFields
} from "@app/lib/launcherResourceAccess";
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
type SiteResourceInfoInput = {
orgId: string;
mode: PrivateResourceMode;
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
ssl: boolean;
domainId?: string | null;
fullDomain?: string | null;
alias?: string | null;
aliasAddress?: string | null;
authDaemonMode?: "site" | "remote" | "native" | null;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
};
type SiteResourceInfoBoxVariant = "settings" | "panel";
type SiteResourceInfoSectionsProps = {
siteResource: SiteResourceInfoInput;
access: LauncherAccessFields;
variant: SiteResourceInfoBoxVariant;
accessClassName?: string;
};
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl,
className
}: LauncherAccessFields & { className?: string }) {
if (!accessDisplay) {
return <span>-</span>;
}
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
if (canLink && href) {
return (
<CopyToClipboard
text={href}
displayText={accessDisplay}
isLink
className={className}
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className={className}
/>
);
}
export function SiteResourceInfoSections({
siteResource,
access,
variant,
accessClassName
}: SiteResourceInfoSectionsProps) {
const t = useTranslations();
const isPanel = variant === "panel";
const modeLabel: Record<PrivateResourceMode, string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const destination = formatSiteResourceDestinationDisplay({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme
});
const portRestrictions = formatPortRestrictionDisplay({
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
});
const showAlias =
siteResource.mode !== "cidr" && siteResource.mode !== "http";
const showDestination = !(
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
);
const showCertificate = !!(
siteResource.mode === "http" &&
siteResource.ssl &&
siteResource.domainId &&
siteResource.fullDomain &&
build != "oss"
);
const numSections =
2 +
(showDestination ? 1 : 0) +
(showAlias ? 1 : 0) +
(showCertificate ? 1 : 0) +
(isPanel ? 1 : 0);
const sections = (
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{modeLabel[siteResource.mode]}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={access.accessDisplay}
accessCopyValue={access.accessCopyValue}
accessUrl={access.accessUrl}
className={accessClassName}
/>
</InfoSectionContent>
</InfoSection>
{showDestination ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogDestination")}
</InfoSectionTitle>
<InfoSectionContent>
{destination || "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showAlias ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogAlias")}
</InfoSectionTitle>
<InfoSectionContent>
{siteResource.alias?.trim() ? siteResource.alias : "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showCertificate ? (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={siteResource.orgId}
domainId={siteResource.domainId!}
fullDomain={siteResource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
) : null}
{isPanel ? (
<InfoSection>
<InfoSectionTitle>{t("portRestrictions")}</InfoSectionTitle>
<InfoSectionContent>
{!portRestrictions.hasNonDefaultPorts ? (
<span>
{t("resourceLauncherNoPortRestrictions")}
</span>
) : (
<div className="space-y-1">
{portRestrictions.tcp.state !== "all" ? (
<div>
{t("resourceLauncherTcp")}:{" "}
{portRestrictions.tcp.state ===
"blocked"
? t("blocked")
: portRestrictions.tcp.ports}
</div>
) : null}
{portRestrictions.udp.state !== "all" ? (
<div>
{t("resourceLauncherUdp")}:{" "}
{portRestrictions.udp.state ===
"blocked"
? t("blocked")
: portRestrictions.udp.ports}
</div>
) : null}
</div>
)}
</InfoSectionContent>
</InfoSection>
) : null}
</InfoSections>
);
if (isPanel) {
return sections;
}
return (
<Alert>
<AlertDescription>{sections}</AlertDescription>
</Alert>
);
}
type SiteResourceInfoBoxProps = {
variant?: "settings";
};
export default function SiteResourceInfoBox({
variant = "settings"
}: SiteResourceInfoBoxProps) {
const { siteResource } = useSiteResourceContext();
const access = formatSiteResourceAccess({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain ?? null,
alias: siteResource.alias ?? null,
aliasAddress: siteResource.aliasAddress ?? null
});
return (
<SiteResourceInfoSections
siteResource={siteResource}
access={access}
variant={variant}
/>
);
}
+19 -23
View File
@@ -7,6 +7,7 @@ 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 { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { ListResourcesResponse } from "@server/routers/resource";
import type ResponseT from "@server/types/Response";
@@ -44,13 +45,13 @@ function isSafeUrlForLink(href: string): boolean {
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
function publicProtocolLabel(r: PublicResourceRow): string {
if (r.http) {
if (r.mode == "http") {
return r.ssl ? "HTTPS" : "HTTP";
}
const p = (r.protocol || "").toLowerCase();
const p = (r.mode || "").toLowerCase();
if (p === "tcp") return "TCP";
if (p === "udp") return "UDP";
return (r.protocol || "—").toUpperCase();
return (r.mode || "—").toUpperCase();
}
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
@@ -68,12 +69,13 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
const modeLabel: Record<SiteResourceRow["mode"], string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp")
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.destinationPort ?? null,
destinationPort: row.destinationPort ?? null,
scheme: row.scheme
});
return (
@@ -90,7 +92,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
const t = useTranslations();
if (!r.http) {
if (!["http", "ssh", "rdp", "vnc"].includes(r.mode || "")) {
return (
<CopyToClipboard
text={r.proxyPort?.toString() ?? ""}
@@ -149,7 +151,7 @@ function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
destination: row.destination,
httpHttpsPort: row.destinationPort,
destinationPort: row.destinationPort,
scheme: row.scheme
});
return (
@@ -420,30 +422,24 @@ export default function SiteResourcesOverview({
publicList.length === 0 &&
privateList.length === 0;
const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`;
const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`;
const publicViewAllHref = `/${orgId}/settings/resources/public?siteId=${siteId}`;
const privateViewAllHref = `/${orgId}/settings/resources/private?siteId=${siteId}`;
const publicRows = publicList.map((r) => ({
key: r.resourceId,
meta: <PublicResourceMeta resource={r} />,
name: r.name,
access: <PublicAccessMethod resource={r} />,
editHref: `/${orgId}/settings/resources/proxy/${r.niceId}`
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
}));
const privateRows = privateList.map((row) => {
const qs = new URLSearchParams({
siteId: String(siteId),
query: row.niceId
});
return {
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: `/${orgId}/settings/resources/client?${qs.toString()}`
};
});
const privateRows = privateList.map((row) => ({
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
}));
if (showEmptyPlaceholder) {
return (
+160
View File
@@ -0,0 +1,160 @@
import { useMemo, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { CheckIcon, Funnel } from "lucide-react";
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
export type SitesColumnFilterButtonProps = {
selectedSiteId: number | null;
onValueChange: (value: number | undefined) => void;
orgId: string;
};
export function SitesColumnFilterButton({
selectedSiteId,
onValueChange,
orgId
}: SitesColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 500
})
);
const selectedSite = useMemo(() => {
let selected = undefined;
if (selectedSiteId) {
selected = sites.find((site) => site.siteId === selectedSiteId) ?? {
siteId: Number(selectedSiteId),
name: t("standaloneHcFilterSiteIdFallback", {
id: Number(selectedSiteId)
}),
type: "newt"
};
}
return selected;
}, [selectedSiteId, sites]);
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
!allSites.find((site) => site.siteId === selectedSite?.siteId)
) {
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-40"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{selectedSite && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
onValueChange(site.siteId);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId === selectedSite?.siteId
? "opacity-100"
: "opacity-0"
)}
/>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{site.name}
</span>
{site.online != null && (
<SiteOnlineStatus
type={site.type}
online={site.online}
/>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { SettingsFormCell } from "@app/components/Settings";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { Badge } from "@app/components/ui/badge";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
export type SshServerSettingsFormFields = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
};
type SshServerSettingsFieldsProps = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
onPamModeChange: (value: "passthrough" | "push") => void;
onStandardDaemonLocationChange: (value: "site" | "remote") => void;
onAuthDaemonPortChange: (value: string) => void;
authDaemonPortError?: string;
sshServerMode: "standard" | "native";
serverModeDisplay: "badge" | "select";
onServerModeChange?: (mode: "standard" | "native") => void;
sshServerModeOptions?: StrategyOption<"standard" | "native">[];
idPrefix?: string;
};
export function SshServerSettingsFields({
pamMode,
standardDaemonLocation,
authDaemonPort,
onPamModeChange,
onStandardDaemonLocationChange,
onAuthDaemonPortChange,
authDaemonPortError,
sshServerMode,
serverModeDisplay,
onServerModeChange,
sshServerModeOptions,
idPrefix = "ssh-server"
}: SshServerSettingsFieldsProps) {
const t = useTranslations();
const isNative = sshServerMode === "native";
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const authMethodOptions = useMemo(
(): StrategyOption<"passthrough" | "push">[] => [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
],
[t]
);
const daemonLocationOptions = useMemo(
(): StrategyOption<"site" | "remote">[] => [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
],
[t]
);
const defaultSshServerModeOptions = useMemo(
(): StrategyOption<"standard" | "native">[] => [
{
id: "native",
title: t("sshServerModePangolin"),
description: t("sshServerModeNativeDescription")
},
{
id: "standard",
title: t("sshServerModeStandard"),
description: t("sshServerModeStandardDescription")
}
],
[t]
);
const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions;
return (
<>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
{serverModeDisplay === "badge" ? (
<Badge variant="secondary">
{sshServerMode === "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
) : (
<StrategySelect<"standard" | "native">
idPrefix={`${idPrefix}-mode`}
value={sshServerMode}
options={modeOptions}
onChange={(value) => onServerModeChange?.(value)}
cols={2}
/>
)}
</div>
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
idPrefix={`${idPrefix}-auth`}
value={pamMode}
options={authMethodOptions}
onChange={onPamModeChange}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
idPrefix={`${idPrefix}-daemon`}
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={onStandardDaemonLocationChange}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label htmlFor={`${idPrefix}-daemon-port`}>
{t("sshDaemonPort")}
</Label>
<Input
id={`${idPrefix}-daemon-port`}
type="number"
min={1}
max={65535}
value={authDaemonPort}
onChange={(e) =>
onAuthDaemonPortChange(e.target.value)
}
/>
{authDaemonPortError ? (
<p className="text-destructive text-sm">
{authDaemonPortError}
</p>
) : null}
</div>
</SettingsFormCell>
)}
</>
);
}
+54 -38
View File
@@ -18,6 +18,7 @@ interface StrategySelectProps<TValue extends string> {
defaultValue?: TValue;
onChange?: (value: TValue) => void;
cols?: number;
idPrefix?: string;
}
export function StrategySelect<TValue extends string>({
@@ -25,11 +26,16 @@ export function StrategySelect<TValue extends string>({
value: controlledValue,
defaultValue,
onChange,
cols
cols = 1,
idPrefix = "strategy"
}: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
const [uncontrolledSelected, setUncontrolledSelected] = useState<
TValue | undefined
>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
const selected = isControlled
? (controlledValue ?? undefined)
: uncontrolledSelected;
return (
<RadioGroup
@@ -39,45 +45,55 @@ export function StrategySelect<TValue extends string>({
if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue);
}}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
style={{
// @ts-expect-error
"--cols": `repeat(${cols}, 1fr)`
}}
className="grid md:grid-cols-(--cols) gap-4"
>
{options.map((option: StrategyOption<TValue>) => (
<label
key={option.id}
htmlFor={option.id}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
{options.map((option: StrategyOption<TValue>) => {
const optionId = `${idPrefix}-${option.id}`;
return (
<label
key={option.id}
htmlFor={optionId}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
<div className="flex-1">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
>
<RadioGroupItem
value={option.id}
id={optionId}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
)}
<div className="flex-1">
<div className="font-medium">
{option.title}
</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
</div>
</div>
</div>
</div>
</label>
))}
</label>
);
})}
</RadioGroup>
);
}
+21 -16
View File
@@ -4,20 +4,23 @@ import { Button } from "@app/components/ui/button";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
export default function SubscriptionViolation() {
interface SubscriptionViolationProps {
billingHref?: string | null;
canViewBilling?: boolean;
}
export default function SubscriptionViolation({
billingHref,
canViewBilling = false
}: SubscriptionViolationProps) {
const context = useSubscriptionStatusContext();
const [isDismissed, setIsDismissed] = useState(false);
const params = useParams();
const orgId = params?.orgId as string | undefined;
const t = useTranslations();
if (!context?.limitsExceeded || isDismissed) return null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-amber-600 text-white p-4 text-center z-50">
<div className="flex flex-wrap justify-center items-center gap-2 sm:gap-4">
@@ -25,16 +28,18 @@ export default function SubscriptionViolation() {
{t("subscriptionViolationMessage")}
</p>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="bg-white/20 hover:bg-white/30 text-white border-0"
asChild
>
<Link href={billingHref}>
{t("subscriptionViolationViewBilling")}
</Link>
</Button>
{canViewBilling && billingHref && (
<Button
variant="secondary"
size="sm"
className="bg-white/20 hover:bg-white/30 text-white border-0"
asChild
>
<Link href={billingHref}>
{t("subscriptionViolationViewBilling")}
</Link>
</Button>
)}
<Button
variant="ghost"
size="sm"
+28 -27
View File
@@ -9,33 +9,34 @@ export default function SupporterMessage({ tier }: { tier: string }) {
const t = useTranslations();
return (
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
<span
className="cursor-pointer"
onClick={(e) => {
// Get the bounding box of the element
const rect = (
e.target as HTMLElement
).getBoundingClientRect();
<></>
// <div className="relative flex items-center space-x-2 whitespace-nowrap group">
// <span
// className="cursor-pointer"
// onClick={(e) => {
// // Get the bounding box of the element
// const rect = (
// e.target as HTMLElement
// ).getBoundingClientRect();
// Trigger confetti centered on the word "Pangolin"
confetti({
particleCount: 100,
spread: 70,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: rect.top / window.innerHeight
},
colors: ["#FFA500", "#FF4500", "#FFD700"]
});
}}
>
Pangolin
</span>
<Star className="w-3 h-3" />
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{t("componentsSupporterMessage", { tier: tier })}
</div>
</div>
// // Trigger confetti centered on the word "Pangolin"
// confetti({
// particleCount: 100,
// spread: 70,
// origin: {
// x: (rect.left + rect.width / 2) / window.innerWidth,
// y: rect.top / window.innerHeight
// },
// colors: ["#FFA500", "#FF4500", "#FFD700"]
// });
// }}
// >
// Pangolin
// </span>
// <Star className="w-3 h-3" />
// <div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
// {t("componentsSupporterMessage", { tier: tier })}
// </div>
// </div>
);
}
+117 -119
View File
@@ -1,14 +1,10 @@
"use client";
import Image from "next/image";
import { Separator } from "@app/components/ui/separator";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
// THIS IS DEPRECATED AND IS NO LONGER SHOWED TO THE USER WITH THE DISCONTINUATION
// OF THE SUPPORTER PROGRAM. IT MAY BE REMOVED IN A FUTURE UPDATE.
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState, useTransition } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
@@ -44,7 +40,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Card,
CardContent,
@@ -63,134 +58,134 @@ interface SupporterStatusProps {
export default function SupporterStatus({
isCollapsed = false
}: SupporterStatusProps) {
const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
// const { supporterStatus, updateSupporterStatus } =
// useSupporterStatusContext();
// const [supportOpen, setSupportOpen] = useState(false);
// const [keyOpen, setKeyOpen] = useState(false);
// const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
// const { env } = useEnvContext();
// const api = createApiClient({ env });
// const t = useTranslations();
const formSchema = z.object({
githubUsername: z.string().nonempty({
error: "GitHub username is required"
}),
key: z.string().nonempty({
error: "Supporter key is required"
})
});
// const formSchema = z.object({
// githubUsername: z.string().nonempty({
// error: "GitHub username is required"
// }),
// key: z.string().nonempty({
// error: "Supporter key is required"
// })
// });
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
githubUsername: "",
key: ""
}
});
// const form = useForm({
// resolver: zodResolver(formSchema),
// defaultValues: {
// githubUsername: "",
// key: ""
// }
// });
async function hide() {
await api.post("/supporter-key/hide");
// async function hide() {
// await api.post("/supporter-key/hide");
updateSupporterStatus({
visible: false
});
}
// updateSupporterStatus({
// visible: false
// });
// }
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const res = await api.post<
AxiosResponse<ValidateSupporterKeyResponse>
>("/supporter-key/validate", {
githubUsername: values.githubUsername,
key: values.key
});
// async function onSubmit(values: z.infer<typeof formSchema>) {
// try {
// const res = await api.post<
// AxiosResponse<ValidateSupporterKeyResponse>
// >("/supporter-key/validate", {
// githubUsername: values.githubUsername,
// key: values.key
// });
const data = res.data.data;
// const data = res.data.data;
if (!data || !data.valid) {
toast({
variant: "destructive",
title: t("supportKeyInvalid"),
description: t("supportKeyInvalidDescription")
});
return;
}
// if (!data || !data.valid) {
// toast({
// variant: "destructive",
// title: t("supportKeyInvalid"),
// description: t("supportKeyInvalidDescription")
// });
// return;
// }
// Trigger the toast
toast({
variant: "default",
title: t("supportKeyValid"),
description: t("supportKeyValidDescription")
});
// // Trigger the toast
// toast({
// variant: "default",
// title: t("supportKeyValid"),
// description: t("supportKeyValidDescription")
// });
// Fireworks-style confetti
const duration = 5 * 1000; // 5 seconds
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
};
// // Fireworks-style confetti
// const duration = 5 * 1000; // 5 seconds
// const animationEnd = Date.now() + duration;
// const defaults = {
// startVelocity: 30,
// spread: 360,
// ticks: 60,
// zIndex: 0,
// colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
// };
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
// function randomInRange(min: number, max: number) {
// return Math.random() * (max - min) + min;
// }
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
// const interval = setInterval(() => {
// const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
// if (timeLeft <= 0) {
// clearInterval(interval);
// return;
// }
const particleCount = 50 * (timeLeft / duration);
// const particleCount = 50 * (timeLeft / duration);
// Launch confetti from two random horizontal positions
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.1, 0.3),
y: Math.random() - 0.2
}
});
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.7, 0.9),
y: Math.random() - 0.2
}
});
}, 250);
// // Launch confetti from two random horizontal positions
// confetti({
// ...defaults,
// particleCount,
// origin: {
// x: randomInRange(0.1, 0.3),
// y: Math.random() - 0.2
// }
// });
// confetti({
// ...defaults,
// particleCount,
// origin: {
// x: randomInRange(0.7, 0.9),
// y: Math.random() - 0.2
// }
// });
// }, 250);
setPurchaseOptionsOpen(false);
setKeyOpen(false);
// setPurchaseOptionsOpen(false);
// setKeyOpen(false);
updateSupporterStatus({
visible: false
});
} catch (error) {
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(
error,
t("supportKeyErrorValidationDescription")
)
});
return;
}
}
// updateSupporterStatus({
// visible: false
// });
// } catch (error) {
// toast({
// variant: "destructive",
// title: t("error"),
// description: formatAxiosError(
// error,
// t("supportKeyErrorValidationDescription")
// )
// });
// return;
// }
// }
return (
<>
<Credenza
{/* <Credenza
open={purchaseOptionsOpen}
onOpenChange={(val) => {
setPurchaseOptionsOpen(val);
@@ -228,7 +223,10 @@ export default function SupporterStatus({
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
<p className="text-sm">
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
<strong>Business & Enterprise Users:</strong>{" "}
For larger organizations or teams requiring
advanced features, consider our self-serve
enterprise license and Enterprise Edition.{" "}
<Link
href="https://pangolin.net/pricing#Self-Hosted"
target="_blank"
@@ -471,7 +469,7 @@ export default function SupporterStatus({
{t("supportKeyBuy")}
</Button>
)
) : null}
) : null} */}
</>
);
}
+12 -3
View File
@@ -43,9 +43,18 @@ export function SwitchInput({
);
return (
<div>
<div className="flex items-center space-x-2 mb-2">
{label && <Label htmlFor={id}>{label}</Label>}
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
{label && (
<Label
htmlFor={id}
className={
disabled ? "opacity-50 cursor-not-allowed" : ""
}
>
{label}
</Label>
)}
<Switch
id={id}
checked={checked}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { Button } from "@app/components/ui/button";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Textarea } from "@app/components/ui/textarea";
import {
applyTextImport,
parsePreviewLines,
parseTextFileItems,
type TextImportFileType,
type TextImportMode
} from "@app/lib/roleFormTextImport";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type TextFileImportDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
currentValue: string;
fieldLabel: string;
parser: (value: string | undefined) => string[];
onConfirm: (value: string) => void;
};
export function TextFileImportDialog({
open,
onOpenChange,
fileName,
fileType,
rawContent,
currentValue,
fieldLabel,
parser,
onConfirm
}: TextFileImportDialogProps) {
const t = useTranslations();
const [editablePreview, setEditablePreview] = useState("");
const [skipHeader, setSkipHeader] = useState(false);
const [mode, setMode] = useState<TextImportMode>("override");
const parsedFromFile = useMemo(
() =>
parseTextFileItems({
content: rawContent,
fileType,
skipHeader,
parser
}),
[rawContent, fileType, skipHeader, parser]
);
useEffect(() => {
setEditablePreview(parsedFromFile.join("\n"));
}, [parsedFromFile]);
const importedItems = useMemo(
() => parsePreviewLines(editablePreview),
[editablePreview]
);
const existingCount = useMemo(
() => parser(currentValue).length,
[currentValue, parser]
);
const totalCount =
mode === "append"
? existingCount + importedItems.length
: importedItems.length;
const modeOptions: OptionSelectOption<TextImportMode>[] = [
{
value: "override",
label: t("roleTextImportOverride")
},
{
value: "append",
label: t("roleTextImportAppend")
}
];
function handleConfirm() {
onConfirm(
applyTextImport({
currentValue,
imported: importedItems,
mode,
parser
})
);
onOpenChange(false);
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("roleTextImportTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("roleTextImportDescription", {
fileName,
fieldLabel
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{fileType === "csv" && (
<CheckboxWithLabel
checked={skipHeader}
onCheckedChange={(checked) => {
if (checked !== "indeterminate") {
setSkipHeader(checked);
}
}}
label={t("roleTextImportSkipHeader")}
/>
)}
<div className="space-y-2">
<p className="text-sm font-medium">
{t("roleTextImportPreview")}
</p>
<Textarea
value={editablePreview}
onChange={(event) =>
setEditablePreview(event.target.value)
}
placeholder={t("roleTextImportEmpty")}
className="min-h-32 text-sm"
/>
</div>
<OptionSelect<TextImportMode>
label={t("roleTextImportMode")}
options={modeOptions}
value={mode}
onChange={setMode}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{mode === "append"
? t("roleTextImportTotalCount", {
existing: existingCount,
imported: importedItems.length,
total: totalCount
})
: t("roleTextImportItemCount", {
count: importedItems.length
})}
</p>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button type="button" variant="outline">
{t("close")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleConfirm}
disabled={importedItems.length === 0}
>
{t("roleTextImportConfirm")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+50 -6
View File
@@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
import {
productUpdatesQueries,
type LatestVersionResponse
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
export type ClientRow = {
id: number;
@@ -100,6 +106,9 @@ export default function UserDevicesTable({
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition();
const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data;
const defaultUserColumnVisibility = {
subnet: false,
@@ -405,7 +414,7 @@ export default function UserDevicesTable({
},
{
accessorKey: "online",
friendlyName: t("connected"),
friendlyName: t("status"),
header: () => {
return (
<ColumnFilterButton
@@ -427,7 +436,7 @@ export default function UserDevicesTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("connected")}
label={t("status")}
className="p-3"
/>
);
@@ -555,6 +564,40 @@ export default function UserDevicesTable({
cell: ({ row }) => {
const originalRow = row.original;
const agentVersionMap: Record<string, string> = {
"Pangolin Windows": "windows",
"Pangolin Android": "android",
"Pangolin iOS": "ios",
"Pangolin iPadOS": "ios",
"Pangolin macOS": "mac",
"Pangolin CLI": "cli",
"Olm CLI": "olm"
};
let updateAvailable = false;
if (
originalRow.olmVersion &&
originalRow.agent &&
latestPlatformVersions
) {
const agent = agentVersionMap[
originalRow.agent
] as keyof LatestVersionResponse;
if (agent in latestPlatformVersions) {
const agentVersion = latestPlatformVersions[agent];
updateAvailable = Boolean(
semver.valid(originalRow.olmVersion) &&
semver.lt(
originalRow.olmVersion,
agentVersion.latestVersion
)
);
}
}
return (
<div className="flex items-center space-x-1">
{originalRow.agent && originalRow.olmVersion ? (
@@ -567,9 +610,9 @@ export default function UserDevicesTable({
"-"
)}
{/*originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)*/}
{updateAvailable && (
<InfoPopup info={t("updateAvailableInfo")} />
)}
</div>
);
}
@@ -714,7 +757,7 @@ export default function UserDevicesTable({
}
return allOptions;
}, [t]);
}, [t, latestPlatformVersions]);
function handleFilterChange(
column: string,
@@ -794,6 +837,7 @@ export default function UserDevicesTable({
columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
pagination={pagination}
rowCount={rowCount}
stickyLeftColumn="name"
@@ -348,7 +348,7 @@ function ResourceMultiSelect({
const [debounced] = useDebounce(q, 150);
const { data: resources = [] } = useQuery(
orgQueries.resources({ orgId, query: debounced, perPage: 10 })
orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 })
);
const shown = useMemo(() => {
@@ -0,0 +1,508 @@
"use client";
import type {
CommandBarNavSection,
SidebarNavSection
} from "@app/app/navigation";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@app/components/ui/command";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { ListUserOrgsResponse } from "@server/routers/org";
import {
ChevronRightIcon,
GlobeIcon,
GlobeLockIcon,
LaptopIcon,
PlugIcon,
ServerIcon,
UserIcon,
X
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from "react";
import { useCanUseCommandPalette } from "./useCanUseCommandPalette";
import { useCommandPaletteActions } from "./useCommandPaletteActions";
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
import { resources } from "@server/db";
type CommandPaletteProps = {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: CommandBarNavSection[];
};
/**
* Plan for command bar:
* - the nav items should be custom items instead of all of the ones in the sidebar
* - actions should be triggered by using `>` (like in Github)
* -> if search starts with `>`, the filter should exclude that char in the filter string
*/
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
const t = useTranslations();
const router = useRouter();
const { open, setOpen } = useCommandPalette();
const [search, setSearch] = useState("");
const isDesktop = useMediaQuery("(min-width: 768px)");
const isActionMode = search.startsWith(">");
const navigationGroups = useCommandPaletteNavigation(navItems);
// const organizations = useCommandPaletteOrganizations(orgs);
const actions = useCommandPaletteActions(orgId, orgs);
const {
shouldSearch,
sites,
publicResources,
privateResources,
users,
machineClients,
userDevices,
isLoading,
hasResults: hasEntityResults
} = useCommandPaletteSearch({
orgId,
query: search,
enabled: !isActionMode && open
});
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setSearch("");
}
},
[setOpen]
);
const runCommand = useCallback(
(command: () => void) => {
setOpen(false);
setSearch("");
command();
},
[setOpen]
);
return (
<CommandDialog
open={open}
onOpenChange={handleOpenChange}
title={t("commandPaletteTitle")}
description={t("commandPaletteDescription")}
className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15"
commandProps={{
loop: true,
filter(value, query) {
let search = query;
if (query.startsWith(">")) {
search = query.substring(1);
console.log({
search,
value
});
}
if (
value
.toLowerCase()
.includes(search.trim().toLowerCase())
) {
return 1;
}
return 0;
}
}}
>
<CommandInput
placeholder={t("commandPaletteSearchPlaceholder")}
value={search}
onValueChange={setSearch}
isLoading={!isActionMode && shouldSearch && isLoading}
trailing={
!isDesktop ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={t("close")}
onClick={() => handleOpenChange(false)}
>
<X className="size-4" />
</Button>
) : undefined
}
/>
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup
heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm"
/>
{!isActionMode &&
navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}>
{groupIndex > 0 && <CommandSeparator />}
<CommandGroup
heading={group.heading}
className={cn(
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5",
groupIndex > 0 &&
"[&_[cmdk-group-heading]]:pt-3"
)}
>
{group.items.map((item) => (
<CommandItem
key={item.id}
value={`${item.title} ${group.heading}`}
onSelect={() =>
runCommand(() =>
router.push(item.href)
)
}
className="h-9"
>
{item.icon}
<span>{item.title}</span>
</CommandItem>
))}
</CommandGroup>
</React.Fragment>
))}
{/* {organizations.length > 1 && (
<>
<CommandSeparator />
<CommandGroup
heading={t("commandPaletteOrganizations")}
>
{organizations.map((org) => (
<CommandItem
key={org.id}
value={`${org.name} ${org.orgId}`}
onSelect={() =>
runCommand(() => router.push(org.href))
}
>
<span className="truncate">{org.name}</span>
<span className="text-xs text-muted-foreground font-mono truncate">
{org.orgId}
</span>
{org.isPrimaryOrg && (
<Badge
variant="outline"
className="ml-auto shrink-0 text-[10px] px-1.5 py-0"
>
{t("primary")}
</Badge>
)}
</CommandItem>
))}
</CommandGroup>
</>
)} */}
{!isActionMode && shouldSearch && orgId && hasEntityResults && (
<CommandGroup
heading={t("commandSearchResults")}
className={cn(
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
)}
>
{sites.map((site) => (
<CommandItem
key={site.id}
value={`${site.name} site`}
onSelect={() =>
runCommand(() => router.push(site.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<PlugIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandSites")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<span className="truncate">
{site.name}
</span>
</div>
</CommandItem>
))}
{publicResources.map((resource) => (
<CommandItem
key={resource.id}
value={`${resource.name} public resource`}
onSelect={() =>
runCommand(() => router.push(resource.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<GlobeIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandProxyResources")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<span className="truncate">
{resource.name}
</span>
</div>
</CommandItem>
))}
{privateResources.map((resource) => (
<CommandItem
key={resource.id}
value={`${resource.name} private resource`}
onSelect={() =>
runCommand(() => router.push(resource.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<GlobeLockIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandClientResources")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<span className="truncate">
{resource.name}
</span>
</div>
</CommandItem>
))}
{users.map((user) => (
<CommandItem
key={user.id}
value={`${user.name} ${user.email}`}
onSelect={() =>
runCommand(() => router.push(user.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<UserIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandUsers")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<div className="inline-flex min-w-0 items-center gap-1">
<span className="truncate">
{user.name}
</span>
<span className="text-muted-foreground">
&middot;
</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</CommandItem>
))}
{machineClients.map((client) => (
<CommandItem
key={client.id}
value={`${client.name} client`}
onSelect={() =>
runCommand(() => router.push(client.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<ServerIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandMachineClients")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<span className="truncate">
{client.name}
</span>
</div>
</CommandItem>
))}
{userDevices.map((device) => (
<CommandItem
key={device.id}
value={`${device.name} user device`}
onSelect={() =>
runCommand(() => router.push(device.href))
}
className="h-9"
>
<div className="inline-flex items-center gap-1">
<LaptopIcon className="size-4 flex-none" />
<span className="text-muted-foreground">
{t("commandUserDevices")}
</span>
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
<span className="truncate">
{device.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{isActionMode && actions.length > 0 && (
<>
<CommandGroup
heading={t("commandPaletteActions")}
className={cn(
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
)}
>
{actions.map((action) => (
<CommandItem
key={action.id}
value={action.label}
onSelect={() =>
runCommand(() => {
if (action.onSelect) {
action.onSelect();
} else if (action.href) {
router.push(action.href);
}
})
}
className="h-9"
>
{action.icon}
<span>{action.label}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
);
}
/*******************************/
/* COMMAND PALETTE CONTEXT */
/*******************************/
export type CommandPaletteContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
};
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
null
);
export function useCommandPalette() {
const context = useContext(CommandPaletteContext);
if (!context) {
throw new Error(
"useCommandPalette must be used within CommandPaletteProvider"
);
}
return context;
}
//*******************************/
/* COMMAND PALETTE PROVIDER */
/*******************************/
type CommandPaletteProviderProps = {
children: React.ReactNode;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
};
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return (
tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
);
}
function CommandPaletteProviderInner({
children,
orgId,
orgs,
navItems
}: CommandPaletteProviderProps) {
const [open, setOpen] = useState(false);
const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs);
const toggle = useCallback(() => {
setOpen((current) => !current);
}, []);
const contextValue = useMemo<CommandPaletteContextValue>(
() => ({
open,
setOpen,
toggle
}),
[open, toggle]
);
useEffect(() => {
if (!canUseCommandPalette) {
return;
}
function onKeyDown(event: KeyboardEvent) {
if (
event.key.toLowerCase() !== "k" ||
!(event.metaKey || event.ctrlKey)
) {
return;
}
if (!open && isEditableTarget(event.target)) {
return;
}
event.preventDefault();
toggle();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canUseCommandPalette, open, toggle]);
return (
<CommandPaletteContext value={contextValue}>
{children}
{canUseCommandPalette ? (
<CommandPalette orgId={orgId} orgs={orgs} navItems={navItems} />
) : null}
</CommandPaletteContext>
);
}
export function CommandPaletteProvider(props: CommandPaletteProviderProps) {
return <CommandPaletteProviderInner {...props} />;
}
@@ -0,0 +1,80 @@
"use client";
import { Button } from "@app/components/ui/button";
import { CommandShortcut } from "@app/components/ui/command";
import { cn } from "@app/lib/cn";
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { ListUserOrgsResponse } from "@server/routers/org";
import { useCommandPalette } from "./CommandPalette";
import { useCanUseCommandPalette } from "./useCanUseCommandPalette";
type CommandPaletteTriggerProps = {
variant?: "header" | "mobile";
className?: string;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
};
function useIsMac() {
const [isMac, setIsMac] = useState(false);
useEffect(() => {
setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform));
}, []);
return isMac;
}
export function CommandPaletteTrigger({
variant = "header",
className,
orgId,
orgs
}: CommandPaletteTriggerProps) {
const t = useTranslations();
const { setOpen } = useCommandPalette();
const isMac = useIsMac();
const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs);
if (!canUseCommandPalette) {
return null;
}
if (variant === "mobile") {
return (
<Button
variant="ghost"
size="icon"
className={className}
aria-label={t("commandPaletteTitle")}
onClick={() => setOpen(true)}
>
<Search className="size-5" />
</Button>
);
}
return (
<Button
variant="outline"
className={cn(
"relative hidden h-9 w-56 justify-start pl-8 pr-3 text-muted-foreground md:flex lg:w-64",
className
)}
aria-label={t("commandPaletteTitle")}
onClick={() => setOpen(true)}
>
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="flex-1 truncate text-left text-sm font-normal">
{t("commandPaletteSearchPlaceholder")}
</span>
<CommandShortcut>
{isMac
? t("commandPaletteShortcutMac")
: t("commandPaletteShortcutWindows")}
</CommandShortcut>
</Button>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useUserContext } from "@app/hooks/useUserContext";
import { ListUserOrgsResponse } from "@server/routers/org";
import { usePathname } from "next/navigation";
export function useCanUseCommandPalette(
orgId?: string,
orgs?: ListUserOrgsResponse["orgs"]
) {
const pathname = usePathname();
const { user } = useUserContext();
if (pathname?.startsWith("/admin")) {
return user.serverAdmin;
}
if (!orgId) {
return false;
}
const currentOrg = orgs?.find((org) => org.orgId === orgId);
return Boolean(currentOrg?.isAdmin || currentOrg?.isOwner);
}
@@ -0,0 +1,157 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { build } from "@server/build";
import {
BellRing,
Building2,
Globe,
GlobeLock,
KeyRound,
MonitorUp,
Plus,
SunMoon,
UserPlus
} from "lucide-react";
import { useTheme } from "next-themes";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { ListUserOrgsResponse } from "@server/routers/org";
export type CommandPaletteAction = {
id: string;
label: string;
icon: ReactNode;
href?: string;
onSelect?: () => void;
};
export function useCommandPaletteActions(
orgId?: string,
orgs?: ListUserOrgsResponse["orgs"]
): CommandPaletteAction[] {
const t = useTranslations();
const pathname = usePathname();
const { env } = useEnvContext();
const { user } = useUserContext();
const { setTheme, theme } = useTheme();
const isAdminPage = pathname?.startsWith("/admin");
return useMemo(() => {
const actions: CommandPaletteAction[] = [];
function cycleTheme() {
const currentTheme = theme || "system";
if (currentTheme === "light") {
setTheme("dark");
} else if (currentTheme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
}
if (isAdminPage) {
actions.push({
id: "create-admin-api-key",
label: t("commandPaletteCreateApiKey"),
icon: <KeyRound className="size-4" />,
href: "/admin/api-keys/create"
});
if (
build === "oss" ||
env?.app.identityProviderMode === "global" ||
env?.app.identityProviderMode === undefined
) {
actions.push({
id: "create-admin-idp",
label: t("commandPaletteCreateIdentityProvider"),
icon: <Plus className="size-4" />,
href: "/admin/idp/create"
});
}
} else if (orgId) {
actions.push({
id: "create-site",
label: t("commandPaletteCreateSite"),
icon: <Plus className="size-4" />,
href: `/${orgId}/settings/sites/create`
});
actions.push({
id: "create-proxy-resource",
label: t("commandPaletteCreateProxyResource"),
icon: <Globe className="size-4" />,
href: `/${orgId}/settings/resources/proxy/create`
});
actions.push({
id: "create-private-resource",
label: t("commandPaletteCreatePrivateResource"),
icon: <GlobeLock className="size-4" />,
href: `/${orgId}/settings/resources/private/create`
});
actions.push({
id: "create-machine-client",
label: t("commandPaletteCreateMachineClient"),
icon: <MonitorUp className="size-4" />,
href: `/${orgId}/settings/clients/machine/create`
});
actions.push({
id: "create-user",
label: t("commandPaletteCreateUser"),
icon: <UserPlus className="size-4" />,
href: `/${orgId}/settings/access/users/create`
});
actions.push({
id: "create-api-key",
label: t("commandPaletteCreateApiKey"),
icon: <KeyRound className="size-4" />,
href: `/${orgId}/settings/api-keys/create`
});
if (!env?.flags.disableEnterpriseFeatures) {
actions.push({
id: "create-alert-rule",
label: t("commandPaletteCreateAlertRule"),
icon: <BellRing className="size-4" />,
href: `/${orgId}/settings/alerting/create`
});
}
if (
(build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined && build !== "oss")
) {
actions.push({
id: "create-idp",
label: t("commandPaletteCreateIdentityProvider"),
icon: <Plus className="size-4" />,
href: `/${orgId}/settings/idp/create`
});
}
}
actions.push({
id: "toggle-theme",
label: t("commandPaletteToggleTheme"),
icon: <SunMoon className="size-4" />,
onSelect: cycleTheme
});
if (user.serverAdmin && !isAdminPage) {
actions.push({
id: "go-admin",
label: t("serverAdmin"),
icon: <Building2 className="size-4" />,
href: "/admin/users"
});
}
return actions;
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
}
@@ -0,0 +1,57 @@
"use client";
import type { CommandBarNavSection } from "@app/app/navigation";
import { flattenNavSections } from "@app/lib/flattenNavItems";
import {
hydrateNavHref,
navHrefParamsFromRoute
} from "@app/lib/hydrateNavHref";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useMemo } from "react";
export type NavigationCommand = {
id: string;
title: string;
href: string;
icon?: React.ReactNode;
sectionHeading: string;
};
export type NavigationCommandGroup = {
heading: string;
items: NavigationCommand[];
};
export function useCommandPaletteNavigation(
navItems: CommandBarNavSection[]
): NavigationCommandGroup[] {
const params = useParams();
const t = useTranslations();
return useMemo(() => {
const hrefParams = navHrefParamsFromRoute(params);
const flat = flattenNavSections(navItems);
const groups = new Map<string, NavigationCommand[]>();
for (const item of flat) {
const href = hydrateNavHref(item.href, hrefParams);
if (!href) continue;
const groupItems = groups.get(item.sectionHeading) ?? [];
groupItems.push({
id: `nav-${item.sectionHeading}-${item.title}-${href}`,
title: t(item.title),
href,
icon: item.icon,
sectionHeading: item.sectionHeading
});
groups.set(item.sectionHeading, groupItems);
}
return Array.from(groups.entries()).map(([heading, items]) => ({
heading: t(heading),
items
}));
}, [navItems, params, t]);
}
@@ -0,0 +1,45 @@
"use client";
import { ListUserOrgsResponse } from "@server/routers/org";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
export type OrganizationCommand = {
id: string;
orgId: string;
name: string;
isPrimaryOrg?: boolean;
href: string;
};
export function useCommandPaletteOrganizations(
orgs: ListUserOrgsResponse["orgs"] | undefined
): OrganizationCommand[] {
const pathname = usePathname();
return useMemo(() => {
if (!orgs?.length) return [];
const sortedOrgs = [...orgs].sort((a, b) => {
const aPrimary = Boolean(a.isPrimaryOrg);
const bPrimary = Boolean(b.isPrimaryOrg);
if (aPrimary && !bPrimary) return -1;
if (!aPrimary && bPrimary) return 1;
return 0;
});
return sortedOrgs.map((org) => {
const newPath = pathname.includes("/settings/")
? pathname.replace(/^\/[^/]+/, `/${org.orgId}`)
: `/${org.orgId}`;
return {
id: `org-${org.orgId}`,
orgId: org.orgId,
name: org.name,
isPrimaryOrg: org.isPrimaryOrg,
href: newPath
};
});
}, [orgs, pathname]);
}
@@ -0,0 +1,198 @@
"use client";
import { orgQueries } from "@app/lib/queries";
import { useQueries } from "@tanstack/react-query";
import { useMemo } from "react";
import { useDebounce } from "use-debounce";
const SEARCH_PER_PAGE = 5;
const MIN_QUERY_LENGTH = 2;
export type SiteSearchResult = {
id: string;
name: string;
href: string;
};
export type ResourceSearchResult = {
id: string;
name: string;
href: string;
};
export type UserSearchResult = {
id: string;
name: string;
email: string;
href: string;
};
export type ClientSearchResult = {
id: string;
name: string;
href: string;
};
export function useCommandPaletteSearch({
orgId,
query,
enabled
}: {
orgId?: string;
query: string;
enabled: boolean;
}) {
const [debouncedQuery] = useDebounce(query, 150);
const trimmedQuery = debouncedQuery.trim();
const shouldSearch =
enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
const [
sitesQuery,
proxyResourcesQuery,
privateResourcesQuery,
usersQuery,
clientsQuery,
userDevicesQuery
] = useQueries({
queries: [
{
...orgQueries.sites({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
},
{
...orgQueries.proxyResources({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
},
{
...orgQueries.privateResources({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
},
{
...orgQueries.users({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
},
{
...orgQueries.machineClients({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
},
{
...orgQueries.userDevices({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
}
]
});
const sites = useMemo((): SiteSearchResult[] => {
if (!orgId || !sitesQuery.data) return [];
return sitesQuery.data.map((site) => ({
id: `site-${site.siteId}`,
name: site.name,
href: `/${orgId}/settings/sites/${site.niceId}`
}));
}, [orgId, sitesQuery.data]);
const publicResources = useMemo((): ResourceSearchResult[] => {
if (!orgId || !proxyResourcesQuery.data) return [];
return proxyResourcesQuery.data.map((resource) => ({
id: `resource-${resource.resourceId}`,
name: resource.name,
href: `/${orgId}/settings/resources/proxy/${resource.niceId}`
}));
}, [orgId, proxyResourcesQuery.data]);
const privateResources = useMemo((): ResourceSearchResult[] => {
if (!orgId || !privateResourcesQuery.data) return [];
return privateResourcesQuery.data.map((resource) => ({
id: `site-resource-${resource.siteResourceId}`,
name: resource.name,
href: `/${orgId}/settings/resources/private/${resource.niceId}`
}));
}, [orgId, privateResourcesQuery.data]);
const users = useMemo((): UserSearchResult[] => {
if (!orgId || !usersQuery.data) return [];
return usersQuery.data.map((user) => ({
id: `user-${user.id}`,
name: user.name ?? user.email ?? user.username ?? "",
email: user.email ?? user.username ?? "",
href: `/${orgId}/settings/access/users/${user.id}`
}));
}, [orgId, usersQuery.data]);
const machineClients = useMemo((): ClientSearchResult[] => {
if (!orgId || !clientsQuery.data) return [];
return clientsQuery.data
.filter((client) => !client.userId)
.map((client) => ({
id: `client-${client.clientId}`,
name: client.name,
href: `/${orgId}/settings/clients/machine/${client.niceId}`
}));
}, [orgId, clientsQuery.data]);
const userDevices = useMemo((): ClientSearchResult[] => {
if (!orgId || !userDevicesQuery.data) return [];
return userDevicesQuery.data
.filter((client) => !client.userId)
.map((client) => ({
id: `client-${client.clientId}`,
name: client.name,
href: `/${orgId}/settings/clients/user/${client.niceId}`
}));
}, [orgId, userDevicesQuery.data]);
const isLoading =
shouldSearch &&
(sitesQuery.isFetching ||
proxyResourcesQuery.isFetching ||
privateResourcesQuery.isFetching ||
usersQuery.isFetching ||
userDevicesQuery.isFetching ||
clientsQuery.isFetching);
const hasResults =
sites.length > 0 ||
publicResources.length > 0 ||
users.length > 0 ||
privateResources.length > 0 ||
userDevices.length > 0 ||
machineClients.length > 0;
return {
debouncedQuery: trimmedQuery,
shouldSearch,
sites,
publicResources,
privateResources,
users,
machineClients,
userDevices,
isLoading,
hasResults
};
}
+62
View File
@@ -0,0 +1,62 @@
import { cn } from "@app/lib/cn";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const labelBadgeClassName =
"inline-flex h-auto items-center gap-1 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
export type LabelBadgeProps = {
name: string;
color: string;
onClick?: () => void;
className?: string;
displayOnly?: boolean;
};
export function LabelBadge({
onClick,
name,
color,
className,
displayOnly = false
}: LabelBadgeProps) {
const content = (
<>
<div
className="size-2 flex-none rounded-full bg-(--color)"
style={{
// @ts-expect-error css color
"--color": color
}}
/>
<span className="relative max-w-24 overflow-hidden text-ellipsis whitespace-nowrap">
{name}
</span>
</>
);
return (
<Tooltip>
<TooltipTrigger asChild>
{displayOnly ? (
<span className={cn(labelBadgeClassName, className)}>
{content}
</span>
) : (
<Button
variant="outline"
onClick={onClick}
className={cn(
labelBadgeClassName,
"cursor-pointer",
className
)}
>
{content}
</Button>
)}
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export type LabelOverflowItem = {
color: string;
name?: string;
};
const labelOverflowBadgeClassName =
"inline-flex h-auto shrink-0 items-center gap-1.5 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
export type LabelOverflowBadgeProps = {
labels: LabelOverflowItem[];
onClick?: () => void;
className?: string;
displayOnly?: boolean;
};
const MAX_OVERFLOW_COLORS = 3;
export function LabelOverflowBadge({
labels,
onClick,
className,
displayOnly = false
}: LabelOverflowBadgeProps) {
const t = useTranslations();
if (labels.length === 0) {
return null;
}
const displayColors = labels
.slice(0, MAX_OVERFLOW_COLORS)
.map((label) => label.color);
const overflowNames = labels
.map((label) => label.name)
.filter((name): name is string => Boolean(name));
const tooltipContent =
overflowNames.length > 0
? overflowNames.join(", ")
: t("labelOverflowCount", { count: labels.length });
const content = (
<>
<span className="inline-flex items-center">
{displayColors.map((color, index) => (
<span
key={index}
className={cn(
"size-2 flex-none rounded-full bg-(--color) ring-1 ring-background",
index > 0 && "-ml-1"
)}
style={{
// @ts-expect-error css color
"--color": color
}}
/>
))}
</span>
<span className="whitespace-nowrap text-muted-foreground">
{t("labelOverflowCount", { count: labels.length })}
</span>
</>
);
return (
<Tooltip>
<TooltipTrigger asChild>
{displayOnly ? (
<span
className={cn(labelOverflowBadgeClassName, className)}
>
{content}
</span>
) : (
<Button
variant="outline"
onClick={onClick}
className={cn(
labelOverflowBadgeClassName,
"cursor-pointer",
className
)}
>
{content}
</Button>
)}
</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
);
}
+285
View File
@@ -0,0 +1,285 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useRef, useState } from "react";
import { useDebounce } from "use-debounce";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
export type SelectedLabel = {
name: string;
color: string;
labelId: number;
};
export type LabelsSelectorProps = {
orgId: string;
selectedLabels: SelectedLabel[];
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
};
export function formatLabelsSelectorLabel(
selectedLabels: SelectedLabel[],
t: (key: string, values?: { count: number }) => string
): string {
if (selectedLabels.length === 0) {
return t("selectLabels");
}
if (selectedLabels.length === 1) {
return selectedLabels[0]!.name;
}
return t("labelsSelectorLabelsCount", {
count: selectedLabels.length
});
}
export const LABEL_COLORS = {
red: "#ff6467",
green: "#05df72",
blue: "#51a2ff",
yellow: "#fdc744",
orange: "#ff8905",
purple: "#a684ff",
gray: "#b4b4b4"
};
export function LabelsSelector({
orgId,
selectedLabels,
toggleLabel
}: LabelsSelectorProps) {
const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
const api = createApiClient(useEnvContext());
const { data: labels = [] } = useQuery(
orgQueries.labels({
orgId,
query: debouncedQuery,
perPage: 10
})
);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(sel) => !base.some((s) => s.labelId === sel.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
const selectedIds = useMemo(
() => new Set(selectedLabels.map((s) => s.labelId)),
[selectedLabels]
);
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const [, action, isPending] = useActionState(createLabel, null);
const createFormRef = useRef<HTMLFormElement>(null);
const trimmedQuery = labelSearchQuery.trim();
const canCreateLabel =
trimmedQuery.length > 0 && labelsShown.length === 0 && !isPending;
async function createLabel(_: any, formData: FormData) {
const name = formData.get("name")?.toString();
const color = formData.get("color")?.toString();
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, { name, color });
const { label } = res.data.data;
toggleLabel(
{
labelId: label.labelId,
name: label.name,
color: label.color
},
"attach"
);
} catch (e: any) {
if (e.response?.status === 409) {
toast({
title: t("labelDuplicateError"),
description: t("labelDuplicateErrorDescription"),
variant: "destructive"
});
} else {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
setlabelsSearchQuery("");
}
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearchOrCreate")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
onKeyDown={(e) => {
if (e.key === "Enter" && canCreateLabel) {
e.preventDefault();
createFormRef.current?.requestSubmit();
}
}}
/>
<CommandList>
<CommandEmpty className="px-3 py-6 text-center text-wrap">
{labelSearchQuery.trim().length > 0 ? (
<div className="flex flex-col gap-2 items-center">
<span className="max-w-34 break-words">
{t("createNewLabel", {
label: labelSearchQuery.trim()
})}
</span>
<form
ref={createFormRef}
action={action}
className="flex items-center gap-2"
>
<input
type="hidden"
name="name"
value={labelSearchQuery.trim()}
/>
<Select defaultValue={randomColor} name="color">
<SelectTrigger className="w-auto min-w-24">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>
{color
.charAt(0)
.toUpperCase() +
color.slice(1)}
</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
variant="outline"
loading={isPending}
type="submit"
>
{t("create")}
</Button>
</form>
</div>
) : (
<div className="flex flex-col gap-1 items-center">
<span className="text-muted-foreground">
{t("labelsNotFound")}
</span>
<span className="text-sm">
{t("labelsEmptyCreateHint")}
</span>
</div>
)}
</CommandEmpty>
<CommandGroup>
{labelsShown.map((label) => (
<CommandItem
key={label.labelId}
value={`${label.labelId}`}
onSelect={() => {
toggleLabel(
label,
selectedIds.has(label.labelId)
? "detach"
: "attach"
);
}}
>
<Checkbox
className="shrink-0"
checked={selectedIds.has(label.labelId)}
onClick={(e) => {
e.stopPropagation();
}}
onPointerDown={(e) => {
e.stopPropagation();
}}
onCheckedChange={(checked) => {
toggleLabel(
label,
checked ? "attach" : "detach"
);
}}
aria-hidden
tabIndex={-1}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span
className="inline-block size-2 flex-none rounded-full bg-(--label-color)"
style={{
// @ts-expect-error CSS variable
"--label-color": label.color
}}
/>
<span className="min-w-0 flex-1 truncate">
{label.name}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
@@ -10,8 +10,14 @@ import {
import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Checkbox } from "../ui/checkbox";
export type TagValue = { text: string; id: string; isAdmin?: boolean };
export type TagValue = {
text: string;
id: string;
isAdmin?: boolean;
color?: string;
};
export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string;
@@ -23,6 +29,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>;
disabled?: boolean;
lockedIds?: Set<string>;
};
export function MultiSelectContent<T extends TagValue>({
@@ -32,7 +39,8 @@ export function MultiSelectContent<T extends TagValue>({
value,
options,
onSearch,
onChange
onChange,
lockedIds
}: MultiSelectTagsProps<T>) {
const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id));
@@ -48,33 +56,44 @@ export function MultiSelectContent<T extends TagValue>({
{emptyPlaceholder ?? t("noResults")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
value={option.id}
key={option.id}
onSelect={() => {
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [...value, option];
}
onChange(newValues);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValues.has(option.id)
? "opacity-100"
: "opacity-0"
{options.map((option) => {
const isLocked = lockedIds?.has(option.id);
return (
<CommandItem
value={option.id}
key={option.id}
disabled={isLocked}
onSelect={() => {
if (isLocked) return;
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [...value, option];
}
onChange(newValues);
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedValues.has(option.id)}
aria-hidden
tabIndex={-1}
/>
{option.color && (
<span
className="size-2 rounded-full flex-none"
style={{
backgroundColor: option.color
}}
/>
)}
/>
{`${option.text}`}
</CommandItem>
))}
{`${option.text}`}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
@@ -5,7 +5,7 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
import {
type MultiSelectTagsProps,
type TagValue,
@@ -16,10 +16,14 @@ export interface MultiSelectInputProps<
T extends TagValue
> extends MultiSelectTagsProps<T> {
buttonText?: string;
lockedIds?: Set<string>;
onPopoverOpenChange?: (open: boolean) => void;
}
export function MultiSelectTagInput<T extends TagValue>({
buttonText,
lockedIds,
onPopoverOpenChange,
...props
}: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id));
@@ -31,6 +35,7 @@ export function MultiSelectTagInput<T extends TagValue>({
// clear input when popover is closed
props.onSearch("");
}
onPopoverOpenChange?.(open);
}}
>
<PopoverTrigger asChild>
@@ -40,58 +45,85 @@ export function MultiSelectTagInput<T extends TagValue>({
buttonVariants({
variant: "outline"
}),
"justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text",
"justify-between w-full flex items-center",
"text-muted-foreground pl-1.5 cursor-text h-auto min-h-9 py-1.5",
"whitespace-normal",
"hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
"flex items-center gap-1 min-w-0 flex-1 flex-wrap"
)}
>
{props.value.map((option) => (
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
)}
onClick={(e) => e.stopPropagation()}
>
{option.text}
<button
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = props.value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
{props.value.map((option) => {
const isLocked = lockedIds?.has(option.id);
return (
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm shrink-0",
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5 whitespace-nowrap",
isLocked && "opacity-60"
)}
onClick={(e) => e.stopPropagation()}
>
<XIcon className="size-3.5" />
</button>
</span>
))}
{option.color && (
<span
className="size-2 rounded-full flex-none ml-1"
style={{
backgroundColor: option.color
}}
/>
)}
<span className="max-w-40 text-ellipsis overflow-hidden">
{option.text}
</span>
{isLocked ? (
<span className="p-0.5 flex-none">
<LockIcon className="size-3" />
</span>
) : (
<button
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (
selectedValues.has(
option.id
)
) {
newValues =
props.value.filter(
(v) =>
v.id !==
option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
>
<XIcon className="size-3.5" />
</button>
)}
</span>
);
})}
<span className="pl-1 font-normal">{buttonText}</span>
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<MultiSelectContent {...props} />
<MultiSelectContent {...props} lockedIds={lockedIds} />
</PopoverContent>
</Popover>
);
+33 -7
View File
@@ -1,4 +1,4 @@
import { orgQueries } from "@app/lib/queries";
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
@@ -19,6 +19,9 @@ export type MultiSitesSelectorProps = {
selectedSites: Selectedsite[];
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
scope?: "org" | "launcher";
onClear?: () => void;
showClear?: boolean;
};
export function formatMultiSitesSelectorLabel(
@@ -40,19 +43,35 @@ export function MultiSitesSelector({
orgId,
selectedSites,
onSelectionChange,
filterTypes
filterTypes,
scope = "org",
onClear,
showClear = false
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
const orgSitesQuery = useQuery({
...orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 10
})
);
}),
enabled: scope === "org"
});
const launcherSitesQuery = useQuery({
...launcherQueries.sites({
orgId,
query: debouncedQuery,
perPage: 20
}),
enabled: scope === "launcher"
});
const sites =
scope === "launcher"
? (launcherSitesQuery.data ?? [])
: (orgSitesQuery.data ?? []);
const sitesShown = useMemo(() => {
const base = filterTypes
@@ -92,6 +111,14 @@ export function MultiSitesSelector({
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
@@ -115,7 +142,6 @@ export function MultiSitesSelector({
<SiteOnlineStatus
type={site.type}
online={site.online}
t={t}
/>
)}
</div>
+120 -37
View File
@@ -10,8 +10,17 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import {
FaApple,
FaCubes,
FaDocker,
FaHdd,
FaLinux,
FaWindows
} from "react-icons/fa";
import { ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem = string | { title: string; command: string };
@@ -20,6 +29,7 @@ const PLATFORMS = [
"macos",
"docker",
"kubernetes",
"advantech",
"podman",
"nixos",
"windows"
@@ -41,13 +51,20 @@ export function NewtSiteInstallCommands({
version = "latest"
}: NewtSiteInstallCommandsProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [acceptClients, setAcceptClients] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
!env.flags.disableEnterpriseFeatures
);
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
);
const showSiteConfiguration = platform !== "advantech";
const supportsSshOption = platform === "linux" || platform === "nixos";
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
? "\n - DISABLE_CLIENTS=true"
@@ -57,6 +74,15 @@ export function NewtSiteInstallCommands({
--set newtInstances[0].acceptClients=true`
: "";
const disableSshFlag =
supportsSshOption &&
!allowPangolinSsh &&
!env.flags.disableEnterpriseFeatures
? " --disable-ssh"
: "";
const runAsRootPrefix =
supportsSshOption && allowPangolinSsh ? "sudo " : "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
linux: {
Run: [
@@ -66,7 +92,7 @@ export function NewtSiteInstallCommands({
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
}
],
"Systemd Service": [
@@ -86,6 +112,11 @@ PANGOLIN_ENDPOINT=${endpoint}${
? `
DISABLE_CLIENTS=true`
: ""
}${
!allowPangolinSsh
? `
DISABLE_SSH=true`
: ""
}
EOF
sudo chmod 600 /etc/newt/newt.env`
@@ -108,7 +139,6 @@ Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
@@ -180,6 +210,9 @@ sudo systemctl enable --now newt`
--set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}`
]
},
advantech: {
Documentation: []
},
podman: {
"Podman Quadlet": [
`[Unit]
@@ -205,7 +238,7 @@ WantedBy=default.target`
},
nixos: {
Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
]
}
};
@@ -257,45 +290,89 @@ WantedBy=default.target`
className="mt-4"
/>
<div className="pt-4">
<p className="font-semibold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
{showSiteConfiguration && (
<div className="pt-4">
<p className="font-semibold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
{supportsSshOption &&
!env.flags.disableEnterpriseFeatures && (
<>
<div className="flex items-center space-x-2 mb-2 mt-2">
<CheckboxWithLabel
id="allowPangolinSsh"
checked={allowPangolinSsh}
onCheckedChange={(checked) => {
const value =
checked as boolean;
setAllowPangolinSsh(value);
}}
label="Allow Pangolin SSH"
/>
</div>
<p
id="allowPangolinSsh-desc"
className="text-sm text-muted-foreground"
>
{t("sitePangolinSshDescription")}
</p>
</>
)}
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
</div>
)}
<div className="pt-4">
<p className="font-semibold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation
information, see{" "}
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noreferrer"
className="underline"
>
docs.pangolin.net/manage/sites/install-kubernetes
</a>
.
{t.rich("siteInstallKubernetesDocsDescription", {
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
})}
</p>
)}
{platform === "advantech" && (
<p className="text-sm text-muted-foreground mb-3">
{t.rich("siteInstallAdvantechDocsDescription", {
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
})}
</p>
)}
<div className="mt-2 space-y-3">
@@ -342,6 +419,8 @@ function getPlatformIcon(platformName: Platform) {
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
return <SiKubernetes className="h-4 w-4 mr-2" />;
case "advantech":
return <FaHdd className="h-4 w-4 mr-2" />;
case "podman":
return <FaCubes className="h-4 w-4 mr-2" />;
case "nixos":
@@ -363,6 +442,8 @@ function getPlatformName(platformName: Platform) {
return "Docker";
case "kubernetes":
return "Kubernetes";
case "advantech":
return "Advantech";
case "podman":
return "Podman";
case "nixos":
@@ -384,6 +465,8 @@ function getArchitectures(platform: Platform) {
return ["Docker Compose", "Docker Run"];
case "kubernetes":
return ["Helm Chart"];
case "advantech":
return ["Documentation"];
case "podman":
return ["Podman Quadlet", "Podman Run"];
case "nixos":
@@ -0,0 +1,43 @@
"use client";
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
type LauncherCopyIconProps = {
text: string;
className?: string;
};
export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
const t = useTranslations();
const [copied, setCopied] = useState(false);
if (!text) {
return null;
}
return (
<button
type="button"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
className
)}
onClick={(event) => {
event.stopPropagation();
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
);
}
@@ -0,0 +1,118 @@
"use client";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { LayoutGrid, SearchX } from "lucide-react";
import { useTranslations } from "next-intl";
type LauncherEmptyStateVariant = "empty" | "noResults";
type LauncherEmptyStateProps = {
variant: LauncherEmptyStateVariant;
layout: "grid" | "list";
query?: string;
onClearFilters?: () => void;
};
function GhostResourceGrid() {
return (
<div
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
aria-hidden
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
>
<div className="flex items-center gap-5">
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
<div className="h-3 w-2/5 rounded bg-muted/40" />
</div>
</div>
</div>
))}
</div>
);
}
function GhostResourceList() {
return (
<div className="flex w-full flex-col" aria-hidden>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
"flex items-center gap-4 px-4 py-3",
index < 2 && "border-b border-border/60"
)}
>
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
<div className="h-3 w-1/4 rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
}
export function LauncherEmptyState({
variant,
layout,
query,
onClearFilters
}: LauncherEmptyStateProps) {
const t = useTranslations();
const isNoResults = variant === "noResults";
const Icon = isNoResults ? SearchX : LayoutGrid;
const trimmedQuery = query?.trim();
return (
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
<div className="pointer-events-none absolute inset-0 opacity-50">
{layout === "grid" ? (
<GhostResourceGrid />
) : (
<GhostResourceList />
)}
</div>
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
{isNoResults
? t("resourceLauncherEmptyStateNoResultsTitle")
: t("resourceLauncherEmptyStateTitle")}
</h3>
<p className="text-sm text-muted-foreground">
{isNoResults
? trimmedQuery
? t(
"resourceLauncherEmptyStateNoResultsWithQuery",
{ query: trimmedQuery }
)
: t(
"resourceLauncherEmptyStateNoResultsDescription"
)
: t("resourceLauncherEmptyStateDescription")}
</p>
</div>
{isNoResults && onClearFilters ? (
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
>
{t("clearAllFilters")}
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import {
formatMultiSitesSelectorLabel,
MultiSitesSelector
} from "@app/components/multi-site-selector";
import {
formatLabelsSelectorLabel,
LABEL_COLORS,
type SelectedLabel
} from "@app/components/labels-selector";
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { launcherQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { ChevronsUpDown, Funnel } from "lucide-react";
import { useMemo, useState } from "react";
import type { Selectedsite } from "@app/components/site-selector";
type LauncherFilterPopoverProps = {
orgId: string;
selectedSites: Selectedsite[];
selectedLabels: SelectedLabel[];
onSitesChange: (sites: Selectedsite[]) => void;
onLabelsChange: (labels: SelectedLabel[]) => void;
};
export function LauncherFilterPopover({
orgId,
selectedSites,
selectedLabels,
onSitesChange,
onLabelsChange
}: LauncherFilterPopoverProps) {
const t = useTranslations();
const [sitesOpen, setSitesOpen] = useState(false);
const [labelsOpen, setLabelsOpen] = useState(false);
const { data: labels = [] } = useQuery(
launcherQueries.labels({
orgId,
perPage: 20
})
);
const { data: sites = [] } = useQuery(
launcherQueries.sites({
orgId,
perPage: 20
})
);
const resolvedSelectedSites: Selectedsite[] = useMemo(
() =>
selectedSites.map((selected) => {
const found = sites.find(
(site) => site.siteId === selected.siteId
);
return found
? {
siteId: found.siteId,
name: found.name,
type: found.type,
online: found.online
}
: selected;
}),
[sites, selectedSites]
);
const selectedLabelIds = useMemo(
() => new Set(selectedLabels.map((label) => label.labelId)),
[selectedLabels]
);
const resolvedSelectedLabels: SelectedLabel[] = useMemo(
() =>
selectedLabels.map((selected) => {
const found = labels.find(
(label) => label.labelId === selected.labelId
);
return (
found ?? {
...selected,
color: selected.color || LABEL_COLORS.gray
}
);
}),
[labels, selectedLabels]
);
const activeFilterCount = selectedSites.length + selectedLabels.length;
return (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative shrink-0"
>
<Funnel className="size-4" />
<span className="sr-only">
{activeFilterCount > 0
? t("resourceLauncherFilterWithCount", {
count: activeFilterCount
})
: t("resourceLauncherFilter")}
</span>
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
>
{activeFilterCount > 99 ? "99+" : activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="flex flex-col gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold">{t("sites")}</p>
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
resolvedSelectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<MultiSitesSelector
orgId={orgId}
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
showClear={selectedSites.length > 0}
onClear={() => {
onSitesChange([]);
}}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold">{t("labels")}</p>
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
selectedLabels.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatLabelsSelectorLabel(
resolvedSelectedLabels,
t
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<LabelsFilterSelector
orgId={orgId}
scope="launcher"
selectedLabels={resolvedSelectedLabels}
isSelected={(label) =>
selectedLabelIds.has(label.labelId)
}
onToggle={(label) => {
if (
selectedLabelIds.has(label.labelId)
) {
onLabelsChange(
selectedLabels.filter(
(item) =>
item.labelId !==
label.labelId
)
);
} else {
onLabelsChange([
...selectedLabels,
label
]);
}
}}
showClear={selectedLabels.length > 0}
onClear={() => {
onLabelsChange([]);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,125 @@
"use client";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import type {
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
import { LauncherResourceGrid } from "./LauncherResourceGrid";
import { LauncherResourceList } from "./LauncherResourceList";
type LauncherFlatResourceListProps = {
orgId: string;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
onClearFilters?: () => void;
onResourceSelect?: (resource: LauncherResource) => void;
};
export function LauncherFlatResourceList({
orgId,
config,
onClearFilters,
onResourceSelect
}: LauncherFlatResourceListProps) {
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const resourceFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
groupKey: LAUNCHER_FLAT_GROUP_KEY,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.resources(orgId, resourceFilters)
});
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !hasNextPage) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<LauncherEmptyState
variant={
hasActiveLauncherFilters(config) ? "noResults" : "empty"
}
layout={config.layout}
query={config.query}
onClearFilters={onClearFilters}
/>
);
}
return (
<div className="flex flex-col gap-2.5">
{config.layout === "grid" ? (
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More