Merge branch 'dev' into alerting-rules

This commit is contained in:
Owen
2026-04-14 21:10:10 -07:00
169 changed files with 13284 additions and 3828 deletions

View File

@@ -491,6 +491,10 @@ export default function BillingPage() {
const currentPlanId = getCurrentPlanId();
const visiblePlanOptions = planOptions.filter(
(plan) => plan.id !== "home" || currentPlanId === "home"
);
// Check if subscription is in a problematic state that requires attention
const hasProblematicSubscription = (): boolean => {
if (!tierSubscription?.subscription) return false;
@@ -803,8 +807,8 @@ export default function BillingPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Plan Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{planOptions.map((plan) => {
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
{visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan);

View File

@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import InvitationsTable, {
InvitationRow
} from "../../../../../components/InvitationsTable";
} from "@app/components/InvitationsTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";

View File

@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { ListUsersResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
import UsersTable, { UserRow } from "@app/components/UsersTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";

View File

@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, {
OrgApiKeyRow
} from "../../../../components/OrgApiKeysTable";
} from "@app/components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from "next-intl/server";

View File

@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>;
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
)}
</div>
<div className="space-y-6">
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
{build != "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable records={dnsRecords} type={domain.type} />

View File

@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "../../../../components/DomainsTable";
import DomainsTable, { DomainRow } from "@app/components/DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";

View File

@@ -79,7 +79,8 @@ const SecurityFormSchema = z.object({
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
settingsLogRetentionDaysAction: z.number(),
settingsLogRetentionDaysConnection: z.number()
});
const LOG_RETENTION_OPTIONS = [
@@ -120,7 +121,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
SecurityFormSchema.pick({
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true
settingsLogRetentionDaysAction: true,
settingsLogRetentionDaysConnection: true
})
),
defaultValues: {
@@ -129,7 +131,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
settingsLogRetentionDaysAccess:
org.settingsLogRetentionDaysAccess ?? 15,
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15
org.settingsLogRetentionDaysAction ?? 15,
settingsLogRetentionDaysConnection:
org.settingsLogRetentionDaysConnection ?? 15
},
mode: "onChange"
});
@@ -155,7 +159,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
data.settingsLogRetentionDaysAction,
settingsLogRetentionDaysConnection:
data.settingsLogRetentionDaysConnection
} as any;
// Update organization
@@ -473,6 +479,107 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysConnection"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.connectionLogs
);
return (
<FormItem>
<FormLabel>
{t(
"logRetentionConnectionLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
// No tier
maxDays = 3;
} else if (subscriptionTier == "enterprise") {
// Enterprise - no limit
return true;
} else if (subscriptionTier == "tier3") {
maxDays = 90;
} else if (subscriptionTier == "tier2") {
maxDays = 30;
} else if (subscriptionTier == "tier1") {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (option.value < 0 || option.value > maxDays) {
return false;
}
return true;
}
).map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>

View File

@@ -465,7 +465,11 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
href={
row.original.type === "ssh"
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
>
<Button
variant="outline"

View File

@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
);
},
cell: ({ row }) => {
const clientType = row.original.clientType === "olm" ? "machine" : "user";
const clientType = row.original.userId ? "user" : "machine";
if (row.original.clientName && row.original.clientNiceId) {
return (
<Link

View File

@@ -0,0 +1,485 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import { Globe, MoreHorizontal, Plus } from "lucide-react";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Destination,
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
import { useTranslations } from "next-intl";
// ── Re-export Destination so the rest of the file can use it ──────────────────
interface ListDestinationsResponse {
destinations: Destination[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
// ── Destination card ───────────────────────────────────────────────────────────
interface DestinationCardProps {
destination: Destination;
onToggle: (id: number, enabled: boolean) => void;
onEdit: (destination: Destination) => void;
onDelete: (destination: Destination) => void;
isToggling: boolean;
disabled?: boolean;
}
function DestinationCard({
destination,
onToggle,
onEdit,
onDelete,
isToggling,
disabled = false
}: DestinationCardProps) {
const t = useTranslations();
const cfg = parseHttpConfig(destination.config);
return (
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
{/* Top row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Squirkle icon: gray outer → white inner → black globe */}
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
<Globe className="h-3.5 w-3.5 text-black" />
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm leading-tight truncate">
{cfg.name || t("streamingUnnamedDestination")}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
HTTP
</p>
</div>
</div>
<Switch
checked={destination.enabled}
onCheckedChange={(v) =>
onToggle(destination.destinationId, v)
}
disabled={isToggling || disabled}
className="shrink-0 mt-0.5"
/>
</div>
{/* URL preview */}
<p className="text-xs text-muted-foreground truncate">
{cfg.url || (
<span className="italic">
{t("streamingNoUrlConfigured")}
</span>
)}
</p>
{/* Footer: edit button + three-dots menu */}
<div className="mt-auto pt-5 flex gap-2">
<Button
variant="outline"
onClick={() => onEdit(destination)}
disabled={disabled}
className="flex-1"
>
{t("edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
disabled={disabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(destination)}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// ── Add destination card ───────────────────────────────────────────────────────
function AddDestinationCard({ onClick }: { onClick: () => void }) {
const t = useTranslations();
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-transparent transition-colors p-5 min-h-35 w-full text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 cursor-pointer"
>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
<Plus className="h-4 w-4" />
</div>
<span className="text-sm font-medium">
{t("streamingAddDestination")}
</span>
</div>
</button>
);
}
// ── Destination type picker ────────────────────────────────────────────────────
type DestinationType = "http" | "s3" | "datadog";
interface DestinationTypePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (type: DestinationType) => void;
isPaywalled?: boolean;
}
function DestinationTypePicker({
open,
onOpenChange,
onSelect,
isPaywalled = false
}: DestinationTypePickerProps) {
const t = useTranslations();
const [selected, setSelected] = useState<DestinationType>("http");
const destinationTypeOptions: ReadonlyArray<
StrategyOption<DestinationType>
> = [
{
id: "http",
title: t("streamingHttpWebhookTitle"),
description: t("streamingHttpWebhookDescription"),
icon: <Globe className="h-6 w-6" />
},
{
id: "s3",
title: t("streamingS3Title"),
description: t("streamingS3Description"),
disabled: true,
icon: (
<Image
src="/third-party/s3.png"
alt={t("streamingS3Title")}
width={24}
height={24}
className="rounded-sm"
/>
)
},
{
id: "datadog",
title: t("streamingDatadogTitle"),
description: t("streamingDatadogDescription"),
disabled: true,
icon: (
<Image
src="/third-party/dd.png"
alt={t("streamingDatadogTitle")}
width={24}
height={24}
className="rounded-sm"
/>
)
}
];
useEffect(() => {
if (open) setSelected("http");
}, [open]);
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-lg">
<CredenzaHeader>
<CredenzaTitle>
{t("streamingAddDestination")}
</CredenzaTitle>
<CredenzaDescription>
{t("streamingTypePickerDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div
className={
isPaywalled ? "pointer-events-none opacity-50" : ""
}
>
<StrategySelect
options={destinationTypeOptions}
value={selected}
onChange={setSelected}
cols={1}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => onSelect(selected)}
disabled={isPaywalled}
>
{t("continue")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function StreamingDestinationsPage() {
const { orgId } = useParams() as { orgId: string };
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isEnterprise = isPaidUser(tierMatrix[TierFeature.SIEM]);
const t = useTranslations();
const [destinations, setDestinations] = useState<Destination[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [typePickerOpen, setTypePickerOpen] = useState(false);
const [editingDestination, setEditingDestination] =
useState<Destination | null>(null);
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
// Delete state
const [deleteTarget, setDeleteTarget] = useState<Destination | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const loadDestinations = useCallback(async () => {
if (build == "oss") {
setDestinations([]);
setLoading(false);
return;
}
try {
const res = await api.get<AxiosResponse<ListDestinationsResponse>>(
`/org/${orgId}/event-streaming-destinations`
);
setDestinations(res.data.data.destinations ?? []);
} catch (e) {
toast({
variant: "destructive",
title: t("streamingFailedToLoad"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setLoading(false);
}
}, [orgId]);
useEffect(() => {
loadDestinations();
}, [loadDestinations]);
const handleToggle = async (destinationId: number, enabled: boolean) => {
// Optimistic update
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId ? { ...d, enabled } : d
)
);
setTogglingIds((prev) => new Set(prev).add(destinationId));
try {
await api.post(
`/org/${orgId}/event-streaming-destination/${destinationId}`,
{ enabled }
);
} catch (e) {
// Revert on failure
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId
? { ...d, enabled: !enabled }
: d
)
);
toast({
variant: "destructive",
title: t("streamingFailedToUpdate"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(destinationId);
return next;
});
}
};
const handleDeleteCard = (destination: Destination) => {
setDeleteTarget(destination);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(
`/org/${orgId}/event-streaming-destination/${deleteTarget.destinationId}`
);
toast({ title: t("streamingDeletedSuccess") });
setDeleteDialogOpen(false);
setDeleteTarget(null);
loadDestinations();
} catch (e) {
toast({
variant: "destructive",
title: t("streamingFailedToDelete"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setDeleting(false);
}
};
const openCreate = () => {
setTypePickerOpen(true);
};
const handleTypePicked = (_type: DestinationType) => {
setTypePickerOpen(false);
setEditingDestination(null);
setModalOpen(true);
};
const openEdit = (destination: Destination) => {
setEditingDestination(destination);
setModalOpen(true);
};
return (
<>
<SettingsSectionTitle
title={t("streamingTitle")}
description={t("streamingDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix[TierFeature.SIEM]} />
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-lg border bg-card p-5 min-h-36 animate-pulse"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{destinations.map((dest) => (
<DestinationCard
key={dest.destinationId}
destination={dest}
onToggle={handleToggle}
onEdit={openEdit}
onDelete={handleDeleteCard}
isToggling={togglingIds.has(dest.destinationId)}
disabled={!isEnterprise}
/>
))}
{/* Add card is always clickable — paywall is enforced inside the picker */}
<AddDestinationCard onClick={openCreate} />
</div>
)}
<DestinationTypePicker
open={typePickerOpen}
onOpenChange={setTypePickerOpen}
onSelect={handleTypePicked}
isPaywalled={!isEnterprise}
/>
<HttpDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
{deleteTarget && (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={(v) => {
setDeleteDialogOpen(v);
if (!v) setDeleteTarget(null);
}}
string={
parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")
}
title={t("streamingDeleteTitle")}
dialog={
<p>
{t("streamingDeleteDialogAreYouSure")}{" "}
<span>
{parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")}
</span>
{t("streamingDeleteDialogPermanentlyRemoved")}
</p>
}
buttonText={t("streamingDeleteButtonText")}
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}

View File

@@ -0,0 +1,84 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "@app/components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
type ProvisioningKeysPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ProvisioningKeysPage(
props: ProvisioningKeysPageProps
) {
const params = await props.params;
const t = await getTranslations();
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
[];
try {
const res = await internal.get<
AxiosResponse<ListSiteProvisioningKeysResponse>
>(
`/org/${params.orgId}/site-provisioning-keys`,
await authCookieHeader()
);
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
} catch (e) {}
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
name: k.name,
id: k.siteProvisioningKeyId,
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
createdAt: k.createdAt,
lastUsed: k.lastUsed,
maxBatchSize: k.maxBatchSize,
numUsed: k.numUsed,
validUntil: k.validUntil,
approveNewSites: k.approveNewSites
}));
return (
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("provisioningKeysBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("provisioningKeysBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("provisioningKeysBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}

View File

@@ -0,0 +1,38 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
interface ProvisioningLayoutProps {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
}
export default async function ProvisioningLayout({
children,
params
}: ProvisioningLayoutProps) {
const { orgId } = await params;
const t = await getTranslations();
const navItems = [
{
title: t("provisioningKeys"),
href: `/${orgId}/settings/provisioning/keys`
},
{
title: t("pendingSites"),
href: `/${orgId}/settings/provisioning/pending`
}
];
return (
<>
<SettingsSectionTitle
title={t("provisioningManage")}
description={t("provisioningDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}

View File

@@ -1,60 +1,10 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { redirect } from "next/navigation";
type ProvisioningPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params;
const t = await getTranslations();
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
[];
try {
const res = await internal.get<
AxiosResponse<ListSiteProvisioningKeysResponse>
>(
`/org/${params.orgId}/site-provisioning-keys`,
await authCookieHeader()
);
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
} catch (e) {}
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
name: k.name,
id: k.siteProvisioningKeyId,
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
createdAt: k.createdAt,
lastUsed: k.lastUsed,
maxBatchSize: k.maxBatchSize,
numUsed: k.numUsed,
validUntil: k.validUntil
}));
return (
<>
<SettingsSectionTitle
title={t("provisioningKeysManage")}
description={t("provisioningKeysDescription")}
/>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
</>
);
}
redirect(`/${params.orgId}/settings/provisioning/keys`);
}

View File

@@ -0,0 +1,116 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import { SiteRow } from "@app/components/SitesTable";
import PendingSitesTable from "@app/components/PendingSitesTable";
import { getTranslations } from "next-intl/server";
import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
type PendingSitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function PendingSitesPage(props: PendingSitesPageProps) {
const params = await props.params;
const incomingSearchParams = new URLSearchParams(await props.searchParams);
incomingSearchParams.set("status", "pending");
let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites?${incomingSearchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
sites = responseData.sites;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
function formatSize(mb: number, type: string): string {
if (type === "local") {
return "-";
}
if (mb >= 1024 * 1024) {
return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) });
} else if (mb >= 1024) {
return t("gigabytes", { count: (mb / 1024).toFixed(2) });
} else {
return t("megabytes", { count: mb.toFixed(2) });
}
}
const siteRows: SiteRow[] = sites.map((site) => ({
name: site.name,
id: site.siteId,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId,
type: site.type as any,
online: site.online,
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
remoteExitNodeId: (site as any).remoteExitNodeId || undefined
}));
return (
<>
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("pendingSitesBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("pendingSitesBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("pendingSitesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() {
...orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org"
}),
enabled: isPaidUser(tierMatrix.orgOidc)
})
});
const pageLoading =

View File

@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -670,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
@@ -774,8 +783,12 @@ function ProxyResourceTargetsForm({
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
title: targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);

View File

@@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
@@ -76,6 +78,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { COUNTRIES } from "@server/db/countries";
import { MAJOR_ASNS } from "@server/db/asns";
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
import {
Command,
CommandEmpty,
@@ -123,7 +126,10 @@ export default function ResourceRules(props: {
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
useState(false);
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
useState(false);
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
@@ -143,14 +149,15 @@ export default function ResourceRules(props: {
IP: "IP",
CIDR: t("ipAddressRange"),
COUNTRY: t("country"),
ASN: "ASN"
ASN: "ASN",
REGION: t("region")
} as const;
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
defaultValues: {
action: "ACCEPT",
match: "IP",
match: "PATH",
value: ""
}
});
@@ -263,6 +270,20 @@ export default function ResourceRules(props: {
setLoading(false);
return;
}
if (
data.match === "REGION" &&
!isValidRegionId(data.value)
) {
toast({
variant: "destructive",
title: t("rulesErrorInvalidRegion"),
description:
t("rulesErrorInvalidRegionDescription") ||
"Invalid region."
});
setLoading(false);
return;
}
// find the highest priority and add one
let priority = data.priority;
@@ -316,6 +337,8 @@ export default function ResourceRules(props: {
return t("rulesMatchCountry");
case "ASN":
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
case "REGION":
return t("rulesMatchRegion");
}
}
@@ -541,16 +564,12 @@ export default function ResourceRules(props: {
<Select
defaultValue={row.original.match}
onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
value === "COUNTRY"
? "US"
: value === "ASN"
? "AS15169"
: row.original.value
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value
})
}
>
@@ -566,6 +585,11 @@ export default function ResourceRules(props: {
{RuleMatch.COUNTRY}
</SelectItem>
)}
{isMaxmindAvailable && (
<SelectItem value="REGION">
{RuleMatch.REGION}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">{RuleMatch.ASN}</SelectItem>
)}
@@ -645,14 +669,14 @@ export default function ResourceRules(props: {
>
{row.original.value
? (() => {
const found = MAJOR_ASNS.find(
const found = MAJOR_ASNS.find(
(asn) =>
asn.code ===
row.original.value
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
);
return found
? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`;
})()
: "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -722,6 +746,88 @@ export default function ResourceRules(props: {
</div>
</PopoverContent>
</Popover>
) : row.original.match === "REGION" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-[200px] justify-between"
>
{(() => {
const regionName = getRegionNameById(row.original.value);
if (!regionName) {
return t("selectRegion");
}
return `${t(regionName)} (${row.original.value})`;
})()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[200px] p-0">
<Command>
<CommandInput
placeholder={t("searchRegions")}
/>
<CommandList>
<CommandEmpty>
{t("noRegionFound")}
</CommandEmpty>
{REGIONS.map((continent) => (
<CommandGroup key={continent.id} heading={t(continent.name)}>
<CommandItem
value={continent.id}
keywords={[
t(continent.name),
continent.id
]}
onSelect={() => {
updateRule(
row.original.ruleId,
{ value: continent.id }
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value === continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} ({continent.id})
</CommandItem>
{continent.includes.map((subregion) => (
<CommandItem
key={subregion.id}
value={subregion.id}
keywords={[
t(subregion.name),
subregion.id
]}
onSelect={() => {
updateRule(
row.original.ruleId,
{ value: subregion.id }
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value === subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)} ({subregion.id})
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
@@ -932,6 +1038,13 @@ export default function ResourceRules(props: {
}
</SelectItem>
)}
{isMaxmindAvailable && (
<SelectItem value="REGION">
{
RuleMatch.REGION
}
</SelectItem>
)}
{isMaxmindAsnAvailable && (
<SelectItem value="ASN">
{
@@ -1197,6 +1310,112 @@ export default function ResourceRules(props: {
</div>
</PopoverContent>
</Popover>
) : addRuleForm.watch(
"match"
) === "REGION" ? (
<Popover
open={
openAddRuleRegionSelect
}
onOpenChange={
setOpenAddRuleRegionSelect
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
aria-expanded={
openAddRuleRegionSelect
}
className="w-full justify-between"
>
{field.value
? (() => {
const regionName = getRegionNameById(field.value);
const translatedName = regionName ? t(regionName) : field.value;
return `${translatedName} (${field.value})`;
})()
: t(
"selectRegion"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder={t(
"searchRegions"
)}
/>
<CommandList>
<CommandEmpty>
{t(
"noRegionFound"
)}
</CommandEmpty>
{REGIONS.map((continent) => (
<CommandGroup key={continent.id} heading={t(continent.name)}>
<CommandItem
value={continent.id}
keywords={[
t(continent.name),
continent.id
]}
onSelect={() => {
field.onChange(
continent.id
);
setOpenAddRuleRegionSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value === continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} ({continent.id})
</CommandItem>
{continent.includes.map((subregion) => (
<CommandItem
key={subregion.id}
value={subregion.id}
keywords={[
t(subregion.name),
subregion.id
]}
onSelect={() => {
field.onChange(
subregion.id
);
setOpenAddRuleRegionSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value === subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)} ({subregion.id})
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input {...field} />
)}

View File

@@ -776,7 +776,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -800,7 +804,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -991,6 +999,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,

View File

@@ -95,7 +95,8 @@ export default async function ProxyResourcesPage(
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
healthStatus: target.healthStatus,
siteName: target.siteName
}))
};
});

View File

@@ -9,7 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, {
ShareLinkRow
} from "../../../../components/ShareLinksTable";
} from "@app/components/ShareLinksTable";
import { getTranslations } from "next-intl/server";
type ShareLinksPageProps = {

View File

@@ -6,9 +6,9 @@ import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteInfoCard from "../../../../../components/SiteInfoCard";
import SiteInfoCard from "@app/components/SiteInfoCard";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) {
const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = {