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"] = {

View File

@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
import ApiKeysTable, { ApiKeyRow } from "../../../components/ApiKeysTable";
import ApiKeysTable, { ApiKeyRow } from "@app/components/ApiKeysTable";
import { getTranslations } from "next-intl/server";
type ApiKeyPageProps = {};

View File

@@ -31,7 +31,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable";
import PolicyTable, { PolicyRow } from "@app/components/PolicyTable";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { ListRolesResponse } from "@server/routers/role";

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 IdpTable, { IdpRow } from "../../../components/AdminIdpTable";
import IdpTable, { IdpRow } from "@app/components/AdminIdpTable";
import { getTranslations } from "next-intl/server";
export default async function IdpPage() {

View File

@@ -6,7 +6,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { LicenseKeysDataTable } from "../../../components/LicenseKeysDataTable";
import { LicenseKeysDataTable } from "@app/components/LicenseKeysDataTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import {
@@ -42,15 +42,21 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Check, Heart, InfoIcon } from "lucide-react";
import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
import Link from "next/link";
import DismissableBanner from "@app/components/DismissableBanner";
import CopyTextBox from "@app/components/CopyTextBox";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "../../../components/SitePriceCalculator";
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
@@ -336,6 +342,47 @@ export default function LicensePage() {
description={t("licenseTitleDescription")}
/>
{!licenseStatus?.isLicenseValid && (
<DismissableBanner
storageKey="license-banner-dismissed"
version={1}
title={t("licenseBannerTitle")}
titleIcon={
<TicketCheck className="w-5 h-5 text-primary" />
}
description={t("licenseBannerDescription")}
>
<Link
href={ENTERPRISE_PRICING_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="default"
size="sm"
className="gap-2"
>
{t("licenseBannerGetLicense")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<Link
href={ENTERPRISE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("licenseBannerViewDocs")}
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
)}
{/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */}

View File

@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
import UsersTable, { GlobalUserRow } from "../../../components/AdminUsersTable";
import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { getTranslations } from "next-intl/server";

View File

@@ -24,6 +24,7 @@ import {
Settings,
SquareMousePointer,
TicketCheck,
Unplug,
User,
UserCog,
Users,
@@ -197,6 +198,11 @@ export const orgNavSections = (
title: "sidebarLogsConnection",
href: "/{orgId}/settings/logs/connection",
icon: <Cable className="size-4 flex-none" />
},
{
title: "sidebarLogsStreaming",
href: "/{orgId}/settings/logs/streaming",
icon: <Unplug className="size-4 flex-none" />
}
]
: [])

View File

@@ -154,7 +154,7 @@ export default function CreateDomainForm({
const punycodePreview = useMemo(() => {
if (!baseDomain) return "";
const punycode = toPunycode(baseDomain);
const punycode = toPunycode(baseDomain.toLowerCase());
return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]);
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
{build != "oss" && env.flags.usePangolinDns ? (
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
) : null}
<FormField
control={form.control}
name="baseDomain"

View File

@@ -79,7 +79,8 @@ export default function CreateSiteProvisioningKeyCredenza({
.max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid")
}),
validUntil: z.string().optional()
validUntil: z.string().optional(),
approveNewSites: z.boolean()
})
.superRefine((data, ctx) => {
const v = data.validUntil;
@@ -103,7 +104,8 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "",
unlimitedBatchSize: false,
maxBatchSize: 100,
validUntil: ""
validUntil: "",
approveNewSites: true
}
});
@@ -114,7 +116,8 @@ export default function CreateSiteProvisioningKeyCredenza({
name: "",
unlimitedBatchSize: false,
maxBatchSize: 100,
validUntil: ""
validUntil: "",
approveNewSites: true
});
}
}, [open, form]);
@@ -123,18 +126,21 @@ export default function CreateSiteProvisioningKeyCredenza({
setLoading(true);
try {
const res = await api
.put<
AxiosResponse<CreateSiteProvisioningKeyResponse>
>(`/org/${orgId}/site-provisioning-key`, {
name: data.name,
maxBatchSize: data.unlimitedBatchSize
? null
: data.maxBatchSize,
validUntil:
data.validUntil == null || data.validUntil.trim() === ""
? undefined
: data.validUntil
})
.put<AxiosResponse<CreateSiteProvisioningKeyResponse>>(
`/org/${orgId}/site-provisioning-key`,
{
name: data.name,
maxBatchSize: data.unlimitedBatchSize
? null
: data.maxBatchSize,
validUntil:
data.validUntil == null ||
data.validUntil.trim() === ""
? undefined
: data.validUntil,
approveNewSites: data.approveNewSites
}
)
.catch((e) => {
toast({
variant: "destructive",
@@ -152,9 +158,7 @@ export default function CreateSiteProvisioningKeyCredenza({
}
}
const credential =
created &&
created.siteProvisioningKey;
const credential = created && created.siteProvisioningKey;
const unlimitedBatchSize = form.watch("unlimitedBatchSize");
@@ -213,15 +217,12 @@ export default function CreateSiteProvisioningKeyCredenza({
min={1}
max={1_000_000}
autoComplete="off"
disabled={
unlimitedBatchSize
}
disabled={unlimitedBatchSize}
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={(e) => {
const v =
e.target.value;
const v = e.target.value;
field.onChange(
v === ""
? 100
@@ -269,9 +270,7 @@ export default function CreateSiteProvisioningKeyCredenza({
const dateTimeValue: DateTimeValue =
(() => {
if (!field.value) return {};
const d = new Date(
field.value
);
const d = new Date(field.value);
if (isNaN(d.getTime()))
return {};
const hours = d
@@ -313,11 +312,7 @@ export default function CreateSiteProvisioningKeyCredenza({
value.date
);
if (value.time) {
const [
h,
m,
s
] =
const [h, m, s] =
value.time.split(
":"
);
@@ -352,6 +347,40 @@ export default function CreateSiteProvisioningKeyCredenza({
);
}}
/>
<FormField
control={form.control}
name="approveNewSites"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
id="provisioning-approve-new-sites"
checked={field.value}
onCheckedChange={(c) =>
field.onChange(
c === true
)
}
/>
</FormControl>
<div className="flex flex-col gap-1">
<FormLabel
htmlFor="provisioning-approve-new-sites"
className="cursor-pointer font-normal !mt-0"
>
{t(
"provisioningKeysApproveNewSites"
)}
</FormLabel>
<FormDescription>
{t(
"provisioningKeysApproveNewSitesDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
</form>
</Form>
)}
@@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({
</CredenzaContent>
</Credenza>
);
}
}

View File

@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
<div className="flex justify-center">
<InputOTP
maxLength={9}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
{...field}
value={field.value
.replace(/-/g, "")

View File

@@ -2,6 +2,7 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Command,
CommandEmpty,
@@ -40,11 +41,15 @@ import {
Check,
CheckCircle2,
ChevronsUpDown,
KeyRound,
Zap
} from "lucide-react";
import { useTranslations } from "next-intl";
import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
type AvailableOption = {
domainNamespaceId: string;
@@ -93,8 +98,15 @@ export default function DomainPicker({
warnOnProvidedDomain = false
}: DomainPickerProps) {
const { env } = useEnvContext();
const { user } = useUserContext();
const api = createApiClient({ env });
const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus();
const requiresPaywall =
build === "saas" &&
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
new Date(user.dateCreated) > new Date("2026-04-13");
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
@@ -509,9 +521,11 @@ export default function DomainPicker({
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
{selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
</div>
) : (
t("domainPickerSelectBaseDomain")
@@ -574,14 +588,23 @@ export default function DomainPicker({
}
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
{orgDomain.type ===
"wildcard"
? t(
"domainPickerVerified"
"domainPickerManual"
)
: t(
"domainPickerUnverified"
: (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
@@ -640,6 +663,7 @@ export default function DomainPicker({
})
}
className="mx-2 rounded-md"
disabled={requiresPaywall}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
@@ -680,6 +704,19 @@ export default function DomainPicker({
</div>
</div>
{requiresPaywall && !hideFreeDomain && (
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" />
<span>
{t("domainPickerFreeDomainsPaidFeature")}
</span>
</div>
</CardContent>
</Card>
)}
{/*showProvidedDomainSearch && build === "saas" && (
<Alert>
<AlertCircle className="h-4 w-4" />

View File

@@ -45,6 +45,7 @@ export type EditableSiteProvisioningKey = {
name: string;
maxBatchSize: number | null;
validUntil: string | null;
approveNewSites: boolean;
};
type EditSiteProvisioningKeyCredenzaProps = {
@@ -76,7 +77,8 @@ export default function EditSiteProvisioningKeyCredenza({
.max(1_000_000, {
message: t("provisioningKeysMaxBatchSizeInvalid")
}),
validUntil: z.string().optional()
validUntil: z.string().optional(),
approveNewSites: z.boolean()
})
.superRefine((data, ctx) => {
const v = data.validUntil;
@@ -100,7 +102,8 @@ export default function EditSiteProvisioningKeyCredenza({
name: "",
unlimitedBatchSize: false,
maxBatchSize: 100,
validUntil: ""
validUntil: "",
approveNewSites: true
}
});
@@ -112,7 +115,8 @@ export default function EditSiteProvisioningKeyCredenza({
name: provisioningKey.name,
unlimitedBatchSize: provisioningKey.maxBatchSize == null,
maxBatchSize: provisioningKey.maxBatchSize ?? 100,
validUntil: provisioningKey.validUntil ?? ""
validUntil: provisioningKey.validUntil ?? "",
approveNewSites: provisioningKey.approveNewSites
});
}, [open, provisioningKey, form]);
@@ -135,7 +139,8 @@ export default function EditSiteProvisioningKeyCredenza({
data.validUntil == null ||
data.validUntil.trim() === ""
? ""
: data.validUntil
: data.validUntil,
approveNewSites: data.approveNewSites
}
)
.catch((e) => {
@@ -255,6 +260,38 @@ export default function EditSiteProvisioningKeyCredenza({
</FormItem>
)}
/>
<FormField
control={form.control}
name="approveNewSites"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
id="provisioning-edit-approve-new-sites"
checked={field.value}
onCheckedChange={(c) =>
field.onChange(c === true)
}
/>
</FormControl>
<div className="flex flex-col gap-1">
<FormLabel
htmlFor="provisioning-edit-approve-new-sites"
className="cursor-pointer font-normal !mt-0"
>
{t(
"provisioningKeysApproveNewSites"
)}
</FormLabel>
<FormDescription>
{t(
"provisioningKeysApproveNewSitesDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="validUntil"

View File

@@ -0,0 +1,773 @@
"use client";
import { useState, useEffect } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { Switch } from "@app/components/ui/switch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Textarea } from "@app/components/ui/textarea";
import { Checkbox } from "@app/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
// ── Types ──────────────────────────────────────────────────────────────────────
export type AuthType = "none" | "bearer" | "basic" | "custom";
export type PayloadFormat = "json_array" | "ndjson" | "json_single";
export interface HttpConfig {
name: string;
url: string;
authType: AuthType;
bearerToken?: string;
basicCredentials?: string;
customHeaderName?: string;
customHeaderValue?: string;
headers: Array<{ key: string; value: string }>;
format: PayloadFormat;
useBodyTemplate: boolean;
bodyTemplate?: string;
}
export interface Destination {
destinationId: number;
orgId: string;
type: string;
config: string;
enabled: boolean;
sendAccessLogs: boolean;
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
createdAt: number;
updatedAt: number;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
export const defaultHttpConfig = (): HttpConfig => ({
name: "",
url: "",
authType: "none",
bearerToken: "",
basicCredentials: "",
customHeaderName: "",
customHeaderValue: "",
headers: [],
format: "json_array",
useBodyTemplate: false,
bodyTemplate: ""
});
export function parseHttpConfig(raw: string): HttpConfig {
try {
return { ...defaultHttpConfig(), ...JSON.parse(raw) };
} catch {
return defaultHttpConfig();
}
}
// ── Headers editor ─────────────────────────────────────────────────────────────
interface HeadersEditorProps {
headers: Array<{ key: string; value: string }>;
onChange: (headers: Array<{ key: string; value: string }>) => void;
}
function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
const t = useTranslations();
const addRow = () => onChange([...headers, { key: "", value: "" }]);
const removeRow = (i: number) =>
onChange(headers.filter((_, idx) => idx !== i));
const updateRow = (i: number, field: "key" | "value", val: string) => {
const next = [...headers];
next[i] = { ...next[i], [field]: val };
onChange(next);
};
return (
<div className="space-y-3">
{headers.length === 0 && (
<p className="text-xs text-muted-foreground">
{t("httpDestNoHeadersConfigured")}
</p>
)}
{headers.map((h, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
value={h.key}
onChange={(e) => updateRow(i, "key", e.target.value)}
placeholder={t("httpDestHeaderNamePlaceholder")}
className="flex-1"
/>
<Input
value={h.value}
onChange={(e) =>
updateRow(i, "value", e.target.value)
}
placeholder={t("httpDestHeaderValuePlaceholder")}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRow(i)}
className="shrink-0 h-9 w-9"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
{t("httpDestAddHeader")}
</Button>
</div>
);
}
// ── Component ──────────────────────────────────────────────────────────────────
export interface HttpDestinationCredenzaProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Destination | null;
orgId: string;
onSaved: () => void;
}
export function HttpDestinationCredenza({
open,
onOpenChange,
editing,
orgId,
onSaved
}: HttpDestinationCredenzaProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [saving, setSaving] = useState(false);
const [cfg, setCfg] = useState<HttpConfig>(defaultHttpConfig());
const [sendAccessLogs, setSendAccessLogs] = useState(false);
const [sendActionLogs, setSendActionLogs] = useState(false);
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
const [sendRequestLogs, setSendRequestLogs] = useState(false);
useEffect(() => {
if (open) {
setCfg(
editing ? parseHttpConfig(editing.config) : defaultHttpConfig()
);
setSendAccessLogs(editing?.sendAccessLogs ?? false);
setSendActionLogs(editing?.sendActionLogs ?? false);
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
setSendRequestLogs(editing?.sendRequestLogs ?? false);
}
}, [open, editing]);
const update = (patch: Partial<HttpConfig>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const urlError: string | null = (() => {
const raw = cfg.url.trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
if (
parsed.protocol !== "http:" &&
parsed.protocol !== "https:"
) {
return t("httpDestUrlErrorHttpRequired");
}
if (build === "saas" && parsed.protocol !== "https:") {
return t("httpDestUrlErrorHttpsRequired");
}
return null;
} catch {
return t("httpDestUrlErrorInvalid");
}
})();
const isValid =
cfg.name.trim() !== "" &&
cfg.url.trim() !== "" &&
urlError === null;
async function handleSave() {
if (!isValid) return;
setSaving(true);
try {
const payload = {
type: "http",
config: JSON.stringify(cfg),
sendAccessLogs,
sendActionLogs,
sendConnectionLogs,
sendRequestLogs
};
if (editing) {
await api.post(
`/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
payload
);
toast({ title: t("httpDestUpdatedSuccess") });
} else {
await api.put(
`/org/${orgId}/event-streaming-destination`,
payload
);
toast({ title: t("httpDestCreatedSuccess") });
}
onSaved();
onOpenChange(false);
} catch (e) {
toast({
variant: "destructive",
title: editing
? t("httpDestUpdateFailed")
: t("httpDestCreateFailed"),
description: formatAxiosError(
e,
t("streamingUnexpectedError")
)
});
} finally {
setSaving(false);
}
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{editing
? t("httpDestEditTitle")
: t("httpDestAddTitle")}
</CredenzaTitle>
<CredenzaDescription>
{editing
? t("httpDestEditDescription")
: t("httpDestAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<HorizontalTabs
clientSide
items={[
{ title: t("httpDestTabSettings"), href: "" },
{ title: t("httpDestTabHeaders"), href: "" },
{ title: t("httpDestTabBody"), href: "" },
{ title: t("httpDestTabLogs"), href: "" }
]}
>
{/* ── Settings tab ────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="dest-name">{t("name")}</Label>
<Input
id="dest-name"
placeholder={t("httpDestNamePlaceholder")}
value={cfg.name}
onChange={(e) =>
update({ name: e.target.value })
}
/>
</div>
{/* URL */}
<div className="space-y-2">
<Label htmlFor="dest-url">
{t("httpDestUrlLabel")}
</Label>
<Input
id="dest-url"
placeholder="https://example.com/webhook"
value={cfg.url}
onChange={(e) =>
update({ url: e.target.value })
}
/>
{urlError && (
<p className="text-xs text-destructive">
{urlError}
</p>
)}
</div>
{/* Authentication */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("httpDestAuthTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestAuthDescription")}
</p>
</div>
<RadioGroup
value={cfg.authType}
onValueChange={(v) =>
update({ authType: v as AuthType })
}
className="gap-2"
>
{/* None */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="none"
id="auth-none"
className="mt-0.5"
/>
<div>
<Label
htmlFor="auth-none"
className="cursor-pointer font-medium"
>
{t("httpDestAuthNoneTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthNoneDescription")}
</p>
</div>
</div>
{/* Bearer */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="bearer"
id="auth-bearer"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-bearer"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBearerTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBearerDescription")}
</p>
</div>
{cfg.authType === "bearer" && (
<Input
placeholder={t("httpDestAuthBearerPlaceholder")}
value={
cfg.bearerToken ?? ""
}
onChange={(e) =>
update({
bearerToken:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Basic */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="basic"
id="auth-basic"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-basic"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBasicTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBasicDescription")}
</p>
</div>
{cfg.authType === "basic" && (
<Input
placeholder={t("httpDestAuthBasicPlaceholder")}
value={
cfg.basicCredentials ??
""
}
onChange={(e) =>
update({
basicCredentials:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Custom */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="custom"
id="auth-custom"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-custom"
className="cursor-pointer font-medium"
>
{t("httpDestAuthCustomTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthCustomDescription")}
</p>
</div>
{cfg.authType === "custom" && (
<div className="flex gap-2">
<Input
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
value={
cfg.customHeaderName ??
""
}
onChange={(e) =>
update({
customHeaderName:
e.target
.value
})
}
className="flex-1"
/>
<Input
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
value={
cfg.customHeaderValue ??
""
}
onChange={(e) =>
update({
customHeaderValue:
e.target
.value
})
}
className="flex-1"
/>
</div>
)}
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Headers tab ──────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestCustomHeadersTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestCustomHeadersDescription")}
</p>
</div>
<HeadersEditor
headers={cfg.headers}
onChange={(headers) => update({ headers })}
/>
</div>
{/* ── Body tab ─────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestBodyTemplateTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestBodyTemplateDescription")}
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="use-body-template"
checked={cfg.useBodyTemplate}
onCheckedChange={(v) =>
update({ useBodyTemplate: v })
}
/>
<Label
htmlFor="use-body-template"
className="cursor-pointer"
>
{t("httpDestEnableBodyTemplate")}
</Label>
</div>
{cfg.useBodyTemplate && (
<div className="space-y-2">
<Label htmlFor="body-template">
{t("httpDestBodyTemplateLabel")}
</Label>
<Textarea
id="body-template"
placeholder={
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "data": {{data}}\n}'
}
value={cfg.bodyTemplate ?? ""}
onChange={(e) =>
update({
bodyTemplate: e.target.value
})
}
className="font-mono text-xs min-h-45 resize-y"
/>
<p className="text-xs text-muted-foreground">
{t("httpDestBodyTemplateHint")}
</p>
</div>
)}
{/* Payload Format */}
<div className="space-y-3">
<div>
<label className="font-medium block">
{t("httpDestPayloadFormatTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestPayloadFormatDescription")}
</p>
</div>
<RadioGroup
value={cfg.format ?? "json_array"}
onValueChange={(v) =>
update({
format: v as PayloadFormat
})
}
className="gap-2"
>
{/* JSON Array */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_array"
id="fmt-json-array"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-array"
className="cursor-pointer font-medium"
>
{t("httpDestFormatJsonArrayTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatJsonArrayDescription")}
</p>
</div>
</div>
{/* NDJSON */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="ndjson"
id="fmt-ndjson"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-ndjson"
className="cursor-pointer font-medium"
>
{t("httpDestFormatNdjsonTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatNdjsonDescription")}
</p>
</div>
</div>
{/* Single event per request */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_single"
id="fmt-json-single"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-single"
className="cursor-pointer font-medium"
>
{t("httpDestFormatSingleTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatSingleDescription")}
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Logs tab ──────────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
{t("httpDestLogTypesTitle")}
</label>
<p className="text-sm text-muted-foreground mt-0.5">
{t("httpDestLogTypesDescription")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-access"
checked={sendAccessLogs}
onCheckedChange={(v) =>
setSendAccessLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-access"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestAccessLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAccessLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-action"
checked={sendActionLogs}
onCheckedChange={(v) =>
setSendActionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-action"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestActionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestActionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-connection"
checked={sendConnectionLogs}
onCheckedChange={(v) =>
setSendConnectionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-connection"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestConnectionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestConnectionLogsDescription")}
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-request"
checked={sendRequestLogs}
onCheckedChange={(v) =>
setSendRequestLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-request"
className="text-sm font-medium cursor-pointer"
>
{t("httpDestRequestLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestRequestLogsDescription")}
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
type="button"
variant="outline"
disabled={saving}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleSave}
loading={saving}
disabled={!isValid || saving}
>
{editing ? t("httpDestSaveChanges") : t("httpDestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -566,19 +566,21 @@ export function InternalResourceForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{variant === "edit" && (
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="siteId"
@@ -612,6 +614,7 @@ export function InternalResourceForm({
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
filterTypes={["newt"]}
onSelectSite={(site) => {
setSelectedSite(site);
field.onChange(site.siteId);

View File

@@ -39,7 +39,11 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [type, setType] = useState<
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
| "rejected"
| "wrong_user"
| "user_does_not_exist"
| "not_logged_in"
| "user_limit_exceeded"
>("rejected");
useEffect(() => {
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
if (!user && type === "user_does_not_exist") {
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else {
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
async function goToSignup() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
Cannot Accept Invite
</p>
<p className="text-center text-sm">
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
This organization has reached its user limit. Please
contact the organization administrator to upgrade their
plan before accepting this invite.
</p>
</div>
);

View File

@@ -12,6 +12,8 @@ import clsx from "clsx";
import { useTransition } from "react";
import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
type Props = {
defaultValue: string;
@@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({
label
}: Props) {
const [isPending, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
// Persist locale to the database (fire-and-forget)
api.post("/user/locale", { locale }).catch(() => {
// Silently ignore errors — cookie is already set as fallback
});
}
const selected = items.find((item) => item.value === defaultValue);

View File

@@ -0,0 +1,473 @@
"use client";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpRight,
Check,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { SiteRow } from "./SitesTable";
type PendingSitesTableProps = {
sites: SiteRow[];
pagination: PaginationState;
orgId: string;
rowCount: number;
};
export default function PendingSitesTable({
sites,
orgId,
pagination,
rowCount
}: PendingSitesTableProps) {
const router = useRouter();
const pathname = usePathname();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition();
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const canUseSiteProvisioning =
isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
build !== "oss";
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
) {
const sp = new URLSearchParams(searchParams);
sp.delete(column);
sp.delete("page");
if (value) {
sp.set(column, value);
}
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
async function approveSite(siteId: number) {
setApprovingIds((prev) => new Set(prev).add(siteId));
try {
await api.post(`/site/${siteId}`, { status: "approved" });
toast({
title: t("success"),
description: t("siteApproveSuccess"),
variant: "default"
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("siteApproveError"),
description: formatAxiosError(e, t("siteApproveError"))
});
} finally {
setApprovingIds((prev) => {
const next = new Set(prev);
next.delete(siteId);
return next;
});
}
}
const columns: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
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: () => {
return <span className="p-3">{t("identifier")}</span>;
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: () => {
return (
<ColumnFilterButton
options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
handleFilterChange("online", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (
originalRow.type == "newt" ||
originalRow.type == "wireguard"
) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
);
}
} else {
return <span>-</span>;
}
}
},
// {
// accessorKey: "mbIn",
// friendlyName: t("dataIn"),
// header: () => {
// const dataInOrder = getSortDirection(
// "megabytesIn",
// searchParams
// );
// const Icon =
// dataInOrder === "asc"
// ? ArrowDown01Icon
// : dataInOrder === "desc"
// ? ArrowUp10Icon
// : ChevronsUpDownIcon;
// return (
// <Button
// variant="ghost"
// onClick={() => toggleSort("megabytesIn")}
// >
// {t("dataIn")}
// <Icon className="ml-2 h-4 w-4" />
// </Button>
// );
// }
// },
// {
// accessorKey: "mbOut",
// friendlyName: t("dataOut"),
// header: () => {
// const dataOutOrder = getSortDirection(
// "megabytesOut",
// searchParams
// );
// const Icon =
// dataOutOrder === "asc"
// ? ArrowDown01Icon
// : dataOutOrder === "desc"
// ? ArrowUp10Icon
// : ChevronsUpDownIcon;
// return (
// <Button
// variant="ghost"
// onClick={() => toggleSort("megabytesOut")}
// >
// {t("dataOut")}
// <Icon className="ml-2 h-4 w-4" />
// </Button>
// );
// }
// },
{
accessorKey: "type",
friendlyName: t("type"),
header: () => {
return <span className="p-3">{t("type")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">WireGuard</Badge>
</div>
);
}
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">Local</Badge>
</div>
);
}
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
if (!originalRow.exitNodeName) {
return "-";
}
const isCloudNode =
build == "saas" &&
originalRow.exitNodeName &&
[
"mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
const capitalizedName =
originalRow.exitNodeName.charAt(0).toUpperCase() +
originalRow.exitNodeName.slice(1).toLowerCase();
return (
<Badge variant="secondary">
Pangolin {capitalizedName}
</Badge>
);
}
if (originalRow.remoteExitNodeId) {
return (
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return <span>{originalRow.exitNodeName}</span>;
}
},
{
accessorKey: "address",
header: () => {
return <span className="p-3">{t("address")}</span>;
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
const isApproving = approvingIds.has(siteRow.id);
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">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={isApproving}
onClick={() => approveSite(siteRow.id)}
>
<Check className="mr-2 w-4 h-4" />
{t("approve")}
</Button>
</div>
);
}
}
];
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 (
<ControlledDataTable
columns={columns}
rows={sites}
tableId="pending-sites-table"
searchPlaceholder={t("searchSitesProgress")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
refreshButtonDisabled={!canUseSiteProvisioning}
rowCount={rowCount}
columnVisibility={{
niceId: false,
nice: false,
exitNode: false,
address: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -54,6 +54,7 @@ export type TargetHealth = {
port: number;
enabled: boolean;
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
};
export type ResourceRow = {
@@ -274,7 +275,9 @@ export default function ProxyResourcesTable({
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
@@ -301,7 +304,9 @@ export default function ProxyResourcesTable({
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
{target.siteName
? `${target.siteName} (${target.ip}:${target.port})`
: `${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled

View File

@@ -10,6 +10,7 @@ import { Button } from "./ui/button";
import { TicketCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useUserContext } from "@app/hooks/useUserContext";
import Link from "next/link";
interface SidebarLicenseButtonProps {
@@ -20,8 +21,11 @@ export default function SidebarLicenseButton({
isCollapsed = false
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const { user } = useUserContext();
const url = "https://docs.pangolin.net/self-host/enterprise-edition";
const url = user?.serverAdmin
? "/admin/license"
: "https://docs.pangolin.net/self-host/enterprise-edition";
const t = useTranslations();

View File

@@ -36,6 +36,7 @@ export type SiteProvisioningKeyRow = {
maxBatchSize: number | null;
numUsed: number;
validUntil: string | null;
approveNewSites: boolean;
};
type SiteProvisioningKeysTableProps = {
@@ -310,6 +311,7 @@ export default function SiteProvisioningKeysTable({
addButtonDisabled={!canUseSiteProvisioning}
onRefresh={refreshData}
isRefreshing={isRefreshing}
refreshButtonDisabled={!canUseSiteProvisioning}
addButtonText={t("provisioningKeysAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"

View File

@@ -342,7 +342,8 @@ export default function SitesTable({
"jupiter",
"saturn",
"uranus",
"neptune"
"neptune",
"pluto"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {

View File

@@ -388,7 +388,7 @@ export default function UserDevicesTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
friendlyName: t("connected"),
header: () => {
return (
<ColumnFilterButton
@@ -410,7 +410,7 @@ export default function UserDevicesTable({
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
label={t("connected")}
className="p-3"
/>
);

View File

@@ -164,7 +164,7 @@ const countryClass = cn(
const highlightedCountryClass = cn(
sharedCountryClass,
"stroke-2",
"stroke-[3]",
"fill-[#f4f4f5]",
"stroke-[#f36117]",
"dark:fill-[#3f3f46]"
@@ -194,11 +194,20 @@ function drawInteractiveCountries(
const path = setupProjetionPath();
const data = parseWorldTopoJsonToGeoJsonFeatures();
const svg = d3.select(element);
const countriesLayer = svg.append("g");
const hoverLayer = svg.append("g").style("pointer-events", "none");
const hoverPath = hoverLayer
.append("path")
.datum(null)
.attr("class", highlightedCountryClass)
.style("display", "none");
svg.selectAll("path")
countriesLayer
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("data-country-path", "true")
.attr("class", countryClass)
.attr("d", path as never)
@@ -209,9 +218,10 @@ function drawInteractiveCountries(
y,
hoveredCountryAlpha3Code: country.properties.a3
});
// brings country to front
this.parentNode?.appendChild(this);
d3.select(this).attr("class", highlightedCountryClass);
hoverPath
.datum(country)
.attr("d", path(country) as string)
.style("display", null);
})
.on("mousemove", function (event) {
@@ -221,13 +231,13 @@ function drawInteractiveCountries(
.on("mouseout", function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
d3.select(this).attr("class", countryClass);
hoverPath.style("display", "none");
});
return svg;
}
type WorldJsonCountryData = { properties: { name: string; a3: string } };
type WorldJsonCountryData = d3.ExtendedFeature<d3.GeoGeometryObjects | null, { name: string; a3: string }>;
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
const collection = topojson.feature(
@@ -257,7 +267,7 @@ function colorInCountriesWithValues(
const svg = d3.select(element);
return svg
.selectAll("path")
.selectAll('path[data-country-path="true"]')
.style("fill", (countryPath) => {
const country = getCountryByCountryPath(countryPath);
if (!country?.count) {

View File

@@ -10,14 +10,14 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react";
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import { SiKubernetes, SiNixos } from "react-icons/si";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"linux",
"macos",
"docker",
"kubernetes",
"podman",
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
const t = useTranslations();
const [acceptClients, setAcceptClients] = useState(true);
const [platform, setPlatform] = useState<Platform>("unix");
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
);
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
: "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: {
All: [
linux: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
"Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("envFile"),
command: `# Create the directory and environment file
sudo install -d -m 0755 /etc/newt
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
NEWT_ID=${id}
NEWT_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
DISABLE_CLIENTS=true` : ""}
EOF
sudo chmod 600 /etc/newt/newt.env`
},
{
title: t("serviceFile"),
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
[Unit]
Description=Newt
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/newt/newt.env
ExecStart=/usr/local/bin/newt
Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF`
},
{
title: t("enableAndStart"),
command: `sudo systemctl daemon-reload
sudo systemctl enable --now newt`
}
]
},
macos: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
@@ -131,7 +191,7 @@ WantedBy=default.target`
]
},
nixos: {
All: [
Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
}
@@ -172,9 +232,9 @@ WantedBy=default.target`
<OptionSelect<string>
label={
["docker", "podman"].includes(platform)
? t("method")
: t("architecture")
platform === "windows"
? t("architecture")
: t("method")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "unix":
return <Terminal className="h-4 w-4 mr-2" />;
case "linux":
return <FaLinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
return <FaLinux className="h-4 w-4 mr-2" />;
}
}
@@ -280,8 +342,10 @@ function getPlatformName(platformName: Platform) {
switch (platformName) {
case "windows":
return "Windows";
case "unix":
return "Unix & macOS";
case "linux":
return "Linux";
case "macos":
return "macOS";
case "docker":
return "Docker";
case "kubernetes":
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
case "nixos":
return "NixOS";
default:
return "Unix / macOS";
return "Linux";
}
}
function getArchitectures(platform: Platform) {
switch (platform) {
case "unix":
return ["All"];
case "linux":
return ["Run", "Systemd Service"];
case "macos":
return ["Run"];
case "windows":
return ["x64"];
case "docker":
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
case "podman":
return ["Podman Quadlet", "Podman Run"];
case "nixos":
return ["All"];
return ["Flake"];
default:
return ["x64"];
return ["Run"];
}
}

View File

@@ -24,12 +24,14 @@ export type SitesSelectorProps = {
orgId: string;
selectedSite?: Selectedsite | null;
onSelectSite: (selected: Selectedsite) => void;
filterTypes?: string[];
};
export function SitesSelector({
orgId,
selectedSite,
onSelectSite
onSelectSite,
filterTypes
}: SitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -45,7 +47,9 @@ export function SitesSelector({
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
const allSites: Array<Selectedsite> = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
@@ -54,7 +58,7 @@ export function SitesSelector({
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
}, [debouncedQuery, sites, selectedSite, filterTypes]);
return (
<Command shouldFilter={false}>

View File

@@ -69,6 +69,7 @@ type ControlledDataTableProps<TData, TValue> = {
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
@@ -91,6 +92,7 @@ export function ControlledDataTable<TData, TValue>({
onAdd,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
@@ -335,7 +337,7 @@ export function ControlledDataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}

View File

@@ -174,6 +174,7 @@ type DataTableProps<TData, TValue> = {
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
@@ -207,6 +208,7 @@ export function DataTable<TData, TValue>({
addButtonDisabled = false,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort,
@@ -624,7 +626,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}

View File

@@ -2,10 +2,13 @@
import { cookies, headers } from "next/headers";
import { Locale, defaultLocale, locales } from "@/i18n/config";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
// In this example the locale is read from a cookie. You could alternatively
// also read it from a database, backend service, or any other source.
const COOKIE_NAME = "NEXT_LOCALE";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds
export async function getUserLocale(): Promise<Locale> {
const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value;
@@ -14,6 +17,32 @@ export async function getUserLocale(): Promise<Locale> {
return cookieLocale as Locale;
}
// No cookie found — try to restore from user's saved locale in DB
try {
const res = await internal.get("/user", await authCookieHeader());
const userLocale = res.data?.data?.locale;
if (userLocale && locales.includes(userLocale as Locale)) {
// Try to cache in a cookie so subsequent requests skip the API
// call. cookies().set() is only permitted in Server Actions and
// Route Handlers — not during rendering — so we isolate it so
// that a write failure doesn't prevent the locale from being
// returned for the current request.
try {
(await cookies()).set(COOKIE_NAME, userLocale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
} catch {
// Cannot set cookies in this context (e.g. during rendering);
// the correct locale is still returned below.
}
return userLocale as Locale;
}
} catch {
// User not logged in or API unavailable — fall through
}
const headerList = await headers();
const acceptLang = headerList.get("accept-language");
@@ -33,5 +62,9 @@ export async function getUserLocale(): Promise<Locale> {
}
export async function setUserLocale(locale: Locale) {
(await cookies()).set(COOKIE_NAME, locale);
(await cookies()).set(COOKIE_NAME, locale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
}